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:  - [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:
co-authored by
Jacob Shandling
parent
ca09fa509b
commit
e25c1c3728
@@ -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>) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -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 (
|
||||
<>
|
||||
|
||||
+44
@@ -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;
|
||||
+119
@@ -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;
|
||||
};
|
||||
+23
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./ScriptBatchStatusTable";
|
||||
+178
@@ -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;
|
||||
+17
@@ -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;
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./ScriptBatchSummaryModal";
|
||||
+2
-2
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+36
-12
@@ -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>
|
||||
|
||||
+3
@@ -13,4 +13,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.loading-spinner.centered {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user