Repository: fleetdm/fleet
Pull request #29025: UI: Add ability to run a script on all hosts that match a set of supported filters; Add UI to view batch run summaries
Author: login: jacobshandling
Created: 2025-05-10T00:25:09Z | Merged: 2025-05-22T23:45:43Z

_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


Diff:
diff --git a/changes/28699-support-running-scripts-on-host-batches-defined-by-filter b/changes/28699-support-running-scripts-on-host-batches-defined-by-filter
new file mode 100644
index 00000000000..2abbb74cb36
--- /dev/null
+++ b/changes/28699-support-running-scripts-on-host-batches-defined-by-filter
@@ -0,0 +1 @@
+- Add ability to run a script on all hosts that match the current set of supported filters
diff --git a/frontend/components/ActivityItem/ActivityItem.tsx b/frontend/components/ActivityItem/ActivityItem.tsx
index e7f2f8e64b5..30e40bba351 100644
--- a/frontend/components/ActivityItem/ActivityItem.tsx
+++ b/frontend/components/ActivityItem/ActivityItem.tsx
@@ -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>) => {
diff --git a/frontend/components/Modal/_styles.scss b/frontend/components/Modal/_styles.scss
index 904e507f7d7..fe59612f1ee 100644
--- a/frontend/components/Modal/_styles.scss
+++ b/frontend/components/Modal/_styles.scss
@@ -49,6 +49,7 @@
     border-bottom: 1px solid $ui-fleet-black-10;
     display: flex;
     justify-content: space-between;
+    min-height: 37px;
 
     span {
       overflow: hidden;
diff --git a/frontend/components/StatusIndicatorWithIcon/StatusIndicatorWithIcon.tsx b/frontend/components/StatusIndicatorWithIcon/StatusIndicatorWithIcon.tsx
index 8c73af4a429..53a9ba6a6ad 100644
--- a/frontend/components/StatusIndicatorWithIcon/StatusIndicatorWithIcon.tsx
+++ b/frontend/components/StatusIndicatorWithIcon/StatusIndicatorWithIcon.tsx
@@ -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;
diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts
index 75206ec1572..8b7524ec3ee 100644
--- a/frontend/interfaces/activity.ts
+++ b/frontend/interfaces/activity.ts
@@ -171,9 +171,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;
diff --git a/frontend/pages/DashboardPage/DashboardPage.tsx b/frontend/pages/DashboardPage/DashboardPage.tsx
index c609c0255ad..72aa825f9aa 100644
--- a/frontend/pages/DashboardPage/DashboardPage.tsx
+++ b/frontend/pages/DashboardPage/DashboardPage.tsx
@@ -629,6 +629,7 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
         setShowActivityFeedTitle={setShowActivityFeedTitle}
         isPremiumTier={isPremiumTier || false}
         setRefetchActivities={setRefetchActivities}
+        router={router}
       />
     ),
   });
diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tests.tsx
index a5e07bda4c3..d7807441fa0 100644
--- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tests.tsx
+++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tests.tsx
@@ -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()}
       />
     );
 
diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx
index fa726da8ae8..285b401c0eb 100644
--- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx
+++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx
@@ -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>
   );
 };
diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx
index 206fa8b3331..6b69e604835 100644
--- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx
+++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx
@@ -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 (
       <>
diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/ScriptBatchStatusTable.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/ScriptBatchStatusTable.tsx
new file mode 100644
index 00000000000..d56abd19c33
--- /dev/null
+++ b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/ScriptBatchStatusTable.tsx
@@ -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;
diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/ScriptBatchStatusTableConfig.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/ScriptBatchStatusTableConfig.tsx
new file mode 100644
index 00000000000..03ba9c01f50
--- /dev/null
+++ b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/ScriptBatchStatusTableConfig.tsx
@@ -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;
+};
diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/_styles.scss b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/_styles.scss
new file mode 100644
index 00000000000..c4a55d846b2
--- /dev/null
+++ b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/_styles.scss
@@ -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;
+      }
+    }
+  }
+}
diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/index.ts b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/index.ts
new file mode 100644
index 00000000000..aa0fe85d9bf
--- /dev/null
+++ b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/index.ts
@@ -0,0 +1 @@
+export { default } from "./ScriptBatchStatusTable";
diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/ScriptBatchSummaryModal.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/ScriptBatchSummaryModal.tsx
new file mode 100644
index 00000000000..80734783e58
--- /dev/null
+++ b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/ScriptBatchSummaryModal.tsx
@@ -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;
diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/_styles.scss b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/_styles.scss
new file mode 100644
index 00000000000..57b7b4bf308
--- /dev/null
+++ b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/_styles.scss
@@ -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;
+}
diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/index.ts b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/index.ts
new file mode 100644
index 00000000000..c2c26f6a968
--- /dev/null
+++ b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/index.ts
@@ -0,0 +1 @@
+export { default } from "./ScriptBatchSummaryModal";
diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusTable/ConfigProfileStatusTableConfig.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusTable/ConfigProfileStatusTableConfig.tsx
index 2ad7ee60b1e..cd0633d6a6e 100644
--- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusTable/ConfigProfileStatusTableConfig.tsx
+++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ConfigProfileStatusTable/ConfigProfileStatusTableConfig.tsx
@@ -18,7 +18,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;
@@ -95,7 +95,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
diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx
index e9eb8bb050c..d248fdb6724 100644
--- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx
+++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx
@@ -67,6 +67,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";
@@ -203,8 +204,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
   );
@@ -221,6 +220,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
@@ -284,13 +287,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;
@@ -396,7 +462,7 @@ const ManageHostsPage = ({
     [
       {
         scope: "hosts",
-        selectedLabels: selectedFilters,
+        selectedLabels,
         globalFilter: searchQuery,
         sortBy,
         teamId: teamIdForApi,
@@ -433,7 +499,7 @@ const ManageHostsPage = ({
   );
 
   const {
-    data: hostsCount,
+    data: totalFilteredHostsCount,
     error: errorHostsCount,
     isFetching: isLoadingHostsCount,
     refetch: refetchHostsCountAPI,
@@ -441,7 +507,7 @@ const ManageHostsPage = ({
     [
       {
         scope: "hosts_count",
-        selectedLabels: selectedFilters,
+        selectedLabels,
         globalFilter: searchQuery,
         teamId: teamIdForApi,
         policyId,
@@ -558,14 +624,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(() => {
@@ -586,10 +652,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;
@@ -1303,7 +1369,7 @@ const ManageHostsPage = ({
       onSubmit={onDeleteHostSubmit}
       onCancel={toggleDeleteHostModal}
       isAllMatchingHostsSelected={isAllMatchingHostsSelected}
-      hostsCount={hostsCount}
+      hostsCount={totalFilteredHostsCount}
       isUpdating={isUpdating}
     />
   );
@@ -1363,7 +1429,7 @@ const ManageHostsPage = ({
     }
 
     let options = {
-      selectedLabels: selectedFilters,
+      selectedLabels,
       globalFilter: searchQuery,
       sortBy,
       teamId: teamIdForApi,
@@ -1419,8 +1485,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}
@@ -1434,7 +1500,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
@@ -1469,7 +1535,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) =>
@@ -1535,9 +1601,16 @@ const ManageHostsPage = ({
       );
     } else if (isAllTeamsSelected && isPremiumTier) {
       disableRunScriptBatchTooltipContent = "Select a team to run a script";
-    } else if (isAllMatchingHostsSelected) {
+    } else if (runScriptBatchFilterNotSupported && isAllMatchingHostsSelected) {
+      disableRunScriptBatchTooltipContent =
+        "Choose different filters to run a script";
+    } else if (
+      // default to blocking until count API responds
+      !totalFilteredHostsCount ||
+      totalFilteredHostsCount > MAX_SCRIPT_BATCH_TARGETS
+    ) {
       disableRunScriptBatchTooltipContent =
-        "Select specific hosts to run a script";
+        "Target at most 5,000 hosts to run a script";
     }
 
     const secondarySelectActions: IActionButtonProps[] = [
@@ -1619,7 +1692,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",
@@ -1778,13 +1851,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}
+          />
+        )}
     </>
   );
 };
diff --git a/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchModal/RunScriptBatchModal.tsx b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchModal/RunScriptBatchModal.tsx
index 1388779c7c4..bf43b33d67d 100644
--- a/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchModal/RunScriptBatchModal.tsx
+++ b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchModal/RunScriptBatchModal.tsx
@@ -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>
diff --git a/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/_styles.scss b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/_styles.scss
index aa637ce7923..18cd682bdab 100644
--- a/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/_styles.scss
+++ b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/_styles.scss
@@ -13,4 +13,7 @@
       }
     }
   }
+  .loading-spinner.centered {
+    margin: auto;
+  }
 }
diff --git a/frontend/services/entities/scripts.ts b/frontend/services/entities/scripts.ts
index 7724107bfc3..ad112d615d6 100644
--- a/frontend/services/entities/scripts.ts
+++ b/frontend/services/entities/scripts.ts
@@ -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)}`
+    );
+  },
 };
diff --git a/frontend/utilities/constants.tsx b/frontend/utilities/constants.tsx
index c481bcf4b5c..2dda929671b 100644
--- a/frontend/utilities/constants.tsx
+++ b/frontend/utilities/constants.tsx
@@ -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;
diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts
index 460de701487..0afcd577029 100644
--- a/frontend/utilities/endpoints.ts
+++ b/frontend/utilities/endpoints.ts
@@ -265,9 +265,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


Reviews:
user: login: sgress454
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T19:28:32Z
user: login: sgress454
body: Works great so far!  Only thing I've noticed is that in `RunScriptBatchModal` we need to update the flash message to say the correct # of hosts (currently says 50).
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T19:32:10Z
user: login: jacobshandling
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T21:46:44Z
user: login: jacobshandling
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T19:49:37Z
user: login: jacobshandling
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T19:50:03Z
user: login: sgress454
body: Looking good!  One nit around number localization.

Another thing we (that is, I) forgot to add to the last review is that since we're limiting this to 5,000 hosts for the first go-round, we need to surface that limit in the UI.  If it's not too much of a lift, we'd like to disable the "Run Script" button if the total is > 5,000 with a tooltip saying "please filter to 5,000 hosts or less".  As a backup (or we can do both) we can inspect the error from the `/scripts/run/batch` endpoint and if it has `reason: "too_many_hosts"`, show a more helpful error message.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T15:14:37Z
user: login: sgress454
body: > None of this is too much of a lift, but want to make sure we are tracking the ever increasing scope creep here for capacity planning's sake and for transparency into how much / how often specs are being amended and extended in flight.

Ok!  Approving this as-is then.
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T21:52:31Z
user: login: sgress454
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T16:47:58Z
user: login: sgress454
body: tested; looks good other than needing to update the API URL 👍 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T17:02:46Z
user: login: sgress454
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T19:37:20Z
user: login: jacobshandling
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T20:33:22Z
user: login: sgress454
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T21:47:58Z

Review comments:
diff_hunk: @@ -284,13 +286,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 = !!(
path: frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx
user: login: sgress454
body: I think we technically can support the [same filters that "transfer" supports](https://github.com/fleetdm/fleet/blob/main/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx#L1107-L1132).  TBH I don't know how some of these even get applied, but we're using identical code on the back end to parse them, so they should work the same.
created_at: 2025-05-14T19:28:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -284,13 +286,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 = !!(
path: frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx
user: login: jacobshandling
body: Interesting...

so [these filters](https://github.com/fleetdm/fleet/blob/28699-expand-batch-script-run-frontend/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx#L1179-L1196) could be removed from this condition and included [here](https://github.com/fleetdm/fleet/blob/28699-expand-batch-script-run-frontend/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx#L1853-L1857) then...@rachaelshaw any reason we shouldn't allow these additional filters?
created_at: 2025-05-14T21:46:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -49,6 +49,7 @@
     border-bottom: 1px solid $ui-fleet-black-10;
     display: flex;
     justify-content: space-between;
+    min-height: 37px;
path: frontend/components/Modal/_styles.scss
user: login: jacobshandling
body: prevents jumping of header on include/exclude `x` due to height of child element
created_at: 2025-05-15T19:49:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,7 +150,7 @@ const RunScriptBatchModal = ({
         onExit={onCancel}
         onEnter={onCancel}
         className={classes}
-        isLoading={isUpdating}
+        disableClosingModal={isUpdating}
path: frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchModal/RunScriptBatchModal.tsx
user: login: jacobshandling
body: this existing pattern removes the x entirely instead of disabling it
created_at: 2025-05-15T19:50:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -68,14 +77,17 @@ 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 : selectedHostIds.length
path: frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchModal/RunScriptBatchModal.tsx
user: login: sgress454
body: use `toLocaleString()` here and in the "will run on" text so that we get commas? Currently it's:

---

<img width="575" alt="image" src="https://github.com/user-attachments/assets/a7104879-e3d9-4928-8da5-4e4f75aeae9d" />

created_at: 2025-05-19T15:10:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -265,9 +265,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/summary/${id}`,
path: frontend/utilities/endpoints.ts
user: login: sgress454
body: ```suggestion
    `/${API_VERSION}/fleet/scripts/batch/summary/${id}`,
```
created_at: 2025-05-21T16:47:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -103,6 +104,15 @@ incompatible or cancelled."
       // for the TS compiler
       return null;
     }
+
+    let scriptsPath = paths.CONTROLS_SCRIPTS;
+    // null team id from API = no team
+    if (statusData.team_id === null) {
path: frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/ScriptBatchSummaryModal.tsx
user: login: sgress454
body: I changed this to `0` in the backend
created_at: 2025-05-21T19:37:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -103,6 +104,15 @@ incompatible or cancelled."
       // for the TS compiler
       return null;
     }
+
+    let scriptsPath = paths.CONTROLS_SCRIPTS;
+    // null team id from API = no team
+    if (statusData.team_id === null) {
path: frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/ScriptBatchSummaryModal.tsx
user: login: jacobshandling
body: Great! So we're in sync as of https://github.com/fleetdm/fleet/commit/5edb793652319ba6c42f1a09ad819e4601225183
created_at: 2025-05-21T20:33:22Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-05-10T00:27:35Z
body: ## [Codecov](https://app.codecov.io/gh/fleetdm/fleet/pull/29025?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm) Report
Attention: Patch coverage is `17.94872%` with `64 lines` in your changes missing coverage. Please review.
> Project coverage is 64.20%. Comparing base [(`305f1b1`)](https://app.codecov.io/gh/fleetdm/fleet/commit/305f1b1b677e5ce2cfd6704f203766d77c1eb214?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm) to head [(`5edb793`)](https://app.codecov.io/gh/fleetdm/fleet/commit/5edb793652319ba6c42f1a09ad819e4601225183?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm).
> Report is 108 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/fleetdm/fleet/pull/29025?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm) | Patch % | Lines |
|---|---|---|
| [...criptBatchSummaryModal/ScriptBatchSummaryModal.tsx](https://app.codecov.io/gh/fleetdm/fleet/pull/29025?src=pr&el=tree&filepath=frontend%2Fpages%2FDashboardPage%2Fcards%2FActivityFeed%2Fcomponents%2FScriptBatchSummaryModal%2FScriptBatchSummaryModal.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm#diff-ZnJvbnRlbmQvcGFnZXMvRGFzaGJvYXJkUGFnZS9jYXJkcy9BY3Rpdml0eUZlZWQvY29tcG9uZW50cy9TY3JpcHRCYXRjaFN1bW1hcnlNb2RhbC9TY3JpcHRCYXRjaFN1bW1hcnlNb2RhbC50c3g=) | 5.71% | [33 Missing :warning: ](https://app.codecov.io/gh/fleetdm/fleet/pull/29025?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm) |
| [...tBatchStatusTable/ScriptBatchStatusTableConfig.tsx](https://app.codecov.io/gh/fleetdm/fleet/pull/29025?src=pr&el=tree&filepath=frontend%2Fpages%2FDashboardPage%2Fcards%2FActivityFeed%2Fcomponents%2FScriptBatchStatusTable%2FScriptBatchStatusTableConfig.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm#diff-ZnJvbnRlbmQvcGFnZXMvRGFzaGJvYXJkUGFnZS9jYXJkcy9BY3Rpdml0eUZlZWQvY29tcG9uZW50cy9TY3JpcHRCYXRjaFN0YXR1c1RhYmxlL1NjcmlwdEJhdGNoU3RhdHVzVGFibGVDb25maWcudHN4) | 25.00% | [15 Missing :warning: ](https://app.codecov.io/gh/fleetdm/fleet/pull/29025?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm) |
| [.../DashboardPage/cards/ActivityFeed/ActivityFeed.tsx](https://app.codecov.io/gh/fleetdm/fleet/pull/29025?src=pr&el=tree&filepath=frontend%2Fpages%2FDashboardPage%2Fcards%2FActivityFeed%2FActivityFeed.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm#diff-ZnJvbnRlbmQvcGFnZXMvRGFzaGJvYXJkUGFnZS9jYXJkcy9BY3Rpdml0eUZlZWQvQWN0aXZpdHlGZWVkLnRzeA==) | 33.33% | [6 Missing :warning: ](https://app.codecov.io/gh/fleetdm/fleet/pull/29025?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm) |
| [.../ScriptBatchStatusTable/ScriptBatchStatusTable.tsx](https://app.codecov.io/gh/fleetdm/fleet/pull/29025?src=pr&el=tree&filepath=frontend%2Fpages%2FDashboardPage%2Fcards%2FActivityFeed%2Fcomponents%2FScriptBatchStatusTable%2FScriptBatchStatusTable.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm#diff-ZnJvbnRlbmQvcGFnZXMvRGFzaGJvYXJkUGFnZS9jYXJkcy9BY3Rpdml0eUZlZWQvY29tcG9uZW50cy9TY3JpcHRCYXRjaFN0YXR1c1RhYmxlL1NjcmlwdEJhdGNoU3RhdHVzVGFibGUudHN4) | 25.00% | [6 Missing :warning: ](https://app.codecov.io/gh/fleetdm/fleet/pull/29025?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm) |
| [frontend/services/entities/scripts.ts](https://app.codecov.io/gh/fleetdm/fleet/pull/29025?src=pr&el=tree&filepath=frontend%2Fservices%2Fentities%2Fscripts.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm#diff-ZnJvbnRlbmQvc2VydmljZXMvZW50aXRpZXMvc2NyaXB0cy50cw==) | 0.00% | [2 Missing :warning: ](https://app.codecov.io/gh/fleetdm/fleet/pull/29025?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm) |
| [frontend/components/ActivityItem/ActivityItem.tsx](https://app.codecov.io/gh/fleetdm/fleet/pull/29025?src=pr&el=tree&filepath=frontend%2Fcomponents%2FActivityItem%2FActivityItem.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm#diff-ZnJvbnRlbmQvY29tcG9uZW50cy9BY3Rpdml0eUl0ZW0vQWN0aXZpdHlJdGVtLnRzeA==) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/fleetdm/fleet/pull/29025?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm) |
| [frontend/utilities/endpoints.ts](https://app.codecov.io/gh/fleetdm/fleet/pull/29025?src=pr&el=tree&filepath=frontend%2Futilities%2Fendpoints.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm#diff-ZnJvbnRlbmQvdXRpbGl0aWVzL2VuZHBvaW50cy50cw==) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/fleetdm/fleet/pull/29025?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main   #29025      +/-   ##
==========================================
- Coverage   64.23%   64.20%   -0.03%     
==========================================
  Files        1830     1840      +10     
  Lines      177627   177821     +194     
  Branches     5262     5305      +43     
==========================================
+ Hits       114094   114178      +84     
- Misses      54588    54699     +111     
+ Partials     8945     8944       -1     
```

| [Flag](https://app.codecov.io/gh/fleetdm/fleet/pull/29025/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm) | Coverage Δ | |
|---|---|---|
| [frontend](https://app.codecov.io/gh/fleetdm/fleet/pull/29025/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm) | `53.52% <17.94%> (-0.17%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/fleetdm/fleet/pull/29025?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=fleetdm).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: sgress454
created_at: 2025-05-15T17:31:16Z
body: A couple of things that came up in design review that are worth addressing in this PR IMO:

1. The loading spinner isn't centered in the modal (becomes more apparent when you're waiting 10+ seconds 😬 ) 
<img width="400" alt="image" src="https://github.com/user-attachments/assets/3353a893-3903-4ff8-a3c2-99af7b326169" />

2. We should disable the “X” and “Done” buttons when in the loading state so that users can’t close the modal and run the script again before the server responds to the first request.

author_association: CONTRIBUTOR
user: login: jacobshandling
created_at: 2025-05-15T19:19:26Z
body: @sgress454 those fixes could even apply to the 4.68 first iteration of this feature, how about I put them in their own PR that can then be cherry picked into that RC?
author_association: CONTRIBUTOR
user: login: sgress454
created_at: 2025-05-15T19:20:58Z
body: > @sgress454 those fixes could even apply to the 4.68 first iteration of this feature, how about I put them in their own PR that can then be cherry picked into that RC?

Up to you but IMO it doesn't warrant a cherry-pick.  For the 4.68 version it all happens so fast you hardly even see the loading spinner and definitely don't have a chance to click either of those buttons before it's done.  And even if you could, the request would finish a split second later and pop up the banner.  These issues only surfaced when the request takes a long time.
author_association: CONTRIBUTOR
user: login: jacobshandling
created_at: 2025-05-15T19:23:21Z
body: Makes sense, will push here shortly then
author_association: CONTRIBUTOR
user: login: jacobshandling
created_at: 2025-05-15T19:51:53Z
body: @sgress454 fixed:
![ezgif-5a57b5d7927be0](https://github.com/user-attachments/assets/64a1657a-d6aa-4386-ab17-e42f8636df0c)

author_association: CONTRIBUTOR
user: login: jacobshandling
created_at: 2025-05-19T23:02:13Z
body: > If it's not too much of a lift, we'd like to disable the "Run Script" button if the total is > 5,000 with a tooltip saying "please filter to 5,000 hosts or less". As a backup (or we can do both) we can inspect the error from the /scripts/run/batch endpoint and if it has reason: "too_many_hosts", show a more helpful error message.

None of this is too much of a lift, but want to make sure we are tracking the ever increasing scope creep here for capacity planning's sake and for transparency into how much / how often specs are being amended and extended in flight. Will add this to another ticket.

~Edit: [ticket](https://github.com/fleetdm/fleet/issues/29281)~

Edit 2: I realize now that this is the same work as 2 branches I already have - adding this to [the current spec expansion ticket](https://github.com/fleetdm/fleet/issues/29143)
author_association: CONTRIBUTOR
user: login: jacobshandling
created_at: 2025-05-21T01:37:45Z
body: @sgress454 I've included all of the updates in this PR to keep things cohesive and in one place, and referenced each of the relevant tickets. Everything you haven't seen yet is in [this single commit](https://github.com/fleetdm/fleet/pull/29025/commits/3a4a94beaeb7e07a31f81e1d75619cb96c6ba787)
author_association: CONTRIBUTOR