mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
# Details This PR merges the feature branch for the scheduled scripts UI into main. This includes the following previously-approved PRs: * https://github.com/fleetdm/fleet/pull/31750 * https://github.com/fleetdm/fleet/pull/31604 * https://github.com/fleetdm/fleet/pull/31797 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [X] Added/updated automated tests - [X] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [X] QA'd all new/changed functionality manually --------- Co-authored-by: jacobshandling <61553566+jacobshandling@users.noreply.github.com> Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
408 lines
12 KiB
TypeScript
408 lines
12 KiB
TypeScript
import React, { useCallback, useContext, useEffect, useState } from "react";
|
|
import { useQuery } from "react-query";
|
|
|
|
import PATHS from "router/paths";
|
|
|
|
import classnames from "classnames";
|
|
|
|
import { Link } from "react-router";
|
|
import Radio from "components/forms/fields/Radio";
|
|
// @ts-ignore
|
|
import InputField from "components/forms/fields/InputField";
|
|
import TooltipWrapper from "components/TooltipWrapper";
|
|
|
|
import { NotificationContext } from "context/notification";
|
|
|
|
import { addTeamIdCriteria, IScript } from "interfaces/script";
|
|
import { getErrorReason } from "interfaces/errors";
|
|
|
|
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
|
|
|
|
import Modal from "components/Modal";
|
|
|
|
import scriptsAPI, {
|
|
IListScriptsQueryKey,
|
|
IScriptBatchSupportedFilters,
|
|
IScriptsResponse,
|
|
IRunScriptBatchRequest,
|
|
} from "services/entities/scripts";
|
|
import ScriptDetailsModal from "pages/hosts/components/ScriptDetailsModal";
|
|
import Spinner from "components/Spinner";
|
|
import EmptyTable from "components/EmptyTable";
|
|
import Button from "components/buttons/Button";
|
|
|
|
import RunScriptBatchPaginatedList from "../RunScriptBatchPaginatedList";
|
|
import { IPaginatedListScript } from "../RunScriptBatchPaginatedList/RunScriptBatchPaginatedList";
|
|
import {
|
|
validateFormData,
|
|
IRunScriptBatchModalFormValidation,
|
|
} from "./helpers";
|
|
|
|
const baseClass = "run-script-batch-modal";
|
|
|
|
export interface IRunScriptBatchModalScheduleFormData {
|
|
date: string;
|
|
time: string;
|
|
}
|
|
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;
|
|
// If we are on the free tier, we don't want to apply any kind of team filters (since the feature is Premium only).
|
|
isFreeTier?: boolean;
|
|
totalFilteredHostsCount: number;
|
|
selectedHostIds: number[];
|
|
onCancel: () => void;
|
|
}
|
|
|
|
const RunScriptBatchModal = ({
|
|
runByFilters = false,
|
|
filters,
|
|
totalFilteredHostsCount,
|
|
selectedHostIds,
|
|
teamId,
|
|
isFreeTier,
|
|
onCancel,
|
|
}: IRunScriptBatchModal) => {
|
|
const { renderFlash } = useContext(NotificationContext);
|
|
|
|
const [currentTimeUTC, setCurrentTimeUTC] = useState<string>("");
|
|
useEffect(() => {
|
|
const intervalId = setInterval(() => {
|
|
const now = new Date();
|
|
const hours = now.getUTCHours().toString().padStart(2, "0");
|
|
const minutes = now.getUTCMinutes().toString().padStart(2, "0");
|
|
setCurrentTimeUTC(`The current time in UTC is ${hours}:${minutes}`);
|
|
}, 1000);
|
|
|
|
// Cleanup function to clear the interval
|
|
return () => clearInterval(intervalId);
|
|
}, []);
|
|
|
|
const [batchRunDate, setBatchRunDate] = useState<string>("");
|
|
const [batchRunTime, setBatchRunTime] = useState<string>("");
|
|
const [
|
|
formValidation,
|
|
setFormValidation,
|
|
] = useState<IRunScriptBatchModalFormValidation>(() =>
|
|
validateFormData({ date: batchRunDate, time: batchRunTime })
|
|
);
|
|
|
|
const [runMode, setRunMode] = useState<"run_now" | "schedule">("run_now");
|
|
const [selectedScript, setSelectedScript] = useState<IScript | undefined>(
|
|
undefined
|
|
);
|
|
const [isUpdating, setIsUpdating] = useState(false);
|
|
const [scriptForDetails, setScriptForDetails] = useState<
|
|
IPaginatedListScript | undefined
|
|
>(undefined);
|
|
// just used to get the total number of scripts, could be optimized by implementing a dedicated scriptsCount endpoint
|
|
const { data: scripts } = useQuery<
|
|
IScriptsResponse,
|
|
Error,
|
|
IScript[],
|
|
IListScriptsQueryKey[]
|
|
>(
|
|
[addTeamIdCriteria({ scope: "scripts" }, teamId, isFreeTier)],
|
|
({ queryKey }) => {
|
|
return scriptsAPI.getScripts(queryKey[0]);
|
|
},
|
|
{
|
|
...DEFAULT_USE_QUERY_OPTIONS,
|
|
keepPreviousData: true,
|
|
select: (data) => {
|
|
return data.scripts || [];
|
|
},
|
|
}
|
|
);
|
|
|
|
// Handle switching between "run now" and "schedule" modes.
|
|
const onChangeRunMode = (mode: "run_now" | "schedule") => {
|
|
setRunMode(mode);
|
|
setFormValidation(
|
|
validateFormData({ date: batchRunDate, time: batchRunTime }, mode)
|
|
);
|
|
};
|
|
|
|
// Handle changes to the date and time inputs.
|
|
const onInputChange = (update: { name: string; value: string }) => {
|
|
if (update.name === "date") {
|
|
setBatchRunDate(update.value);
|
|
} else if (update.name === "time") {
|
|
setBatchRunTime(update.value);
|
|
}
|
|
setFormValidation(
|
|
validateFormData(
|
|
{
|
|
date: batchRunDate,
|
|
time: batchRunTime,
|
|
[update.name]: update.value,
|
|
},
|
|
runMode
|
|
)
|
|
);
|
|
};
|
|
|
|
const onRunScriptBatch = useCallback(
|
|
async (script: IScript) => {
|
|
setIsUpdating(true);
|
|
|
|
// Create the base request.
|
|
let body: IRunScriptBatchRequest;
|
|
|
|
if (runByFilters) {
|
|
body = {
|
|
script_id: script.id,
|
|
filters: addTeamIdCriteria(filters, teamId, isFreeTier),
|
|
};
|
|
} else {
|
|
body = {
|
|
script_id: script.id,
|
|
host_ids: selectedHostIds,
|
|
};
|
|
}
|
|
|
|
// Add not_before if scheduling
|
|
if (runMode === "schedule") {
|
|
body.not_before = `${batchRunDate}T${batchRunTime}:00.000Z`;
|
|
}
|
|
|
|
try {
|
|
await scriptsAPI.runScriptBatch(body);
|
|
if (runMode === "schedule") {
|
|
renderFlash(
|
|
"success",
|
|
<span className={`${baseClass}__success-message`}>
|
|
<span>Successfully scheduled script.</span>
|
|
<Link
|
|
to={`${PATHS.CONTROLS_SCRIPTS_BATCH_PROGRESS}?status=scheduled&team_id=${teamId})`}
|
|
>
|
|
Show schedule
|
|
</Link>
|
|
</span>
|
|
);
|
|
} else {
|
|
renderFlash(
|
|
"success",
|
|
<span className={`${baseClass}__success-message`}>
|
|
<span>Successfully ran script.</span>
|
|
<Link
|
|
to={`${PATHS.CONTROLS_SCRIPTS_BATCH_PROGRESS}?status=started&team_id=${teamId})`}
|
|
>
|
|
Show script activity
|
|
</Link>
|
|
</span>
|
|
);
|
|
}
|
|
onCancel();
|
|
// TODO -- redirect to the batch scripts page.
|
|
} catch (error) {
|
|
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);
|
|
}
|
|
},
|
|
[renderFlash, selectedHostIds, runMode, batchRunDate, batchRunTime]
|
|
);
|
|
|
|
const renderModalContent = () => {
|
|
if (scripts === undefined) {
|
|
return <Spinner />;
|
|
}
|
|
if (!scripts.length) {
|
|
return (
|
|
<EmptyTable
|
|
header="No scripts available for this team"
|
|
info={
|
|
<>
|
|
You can add saved scripts{" "}
|
|
<a
|
|
href={
|
|
isFreeTier
|
|
? "/controls/scripts"
|
|
: `/controls/scripts?team_id=${teamId}`
|
|
}
|
|
>
|
|
here
|
|
</a>
|
|
.
|
|
</>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
if (!selectedScript) {
|
|
const targetCount = runByFilters
|
|
? totalFilteredHostsCount
|
|
: selectedHostIds.length;
|
|
return (
|
|
<>
|
|
<p>
|
|
Run a script on{" "}
|
|
<b>
|
|
{targetCount.toLocaleString()} host{targetCount > 1 ? "s" : ""}
|
|
</b>
|
|
, or schedule a script to run on targeted hosts in the future.
|
|
</p>
|
|
<RunScriptBatchPaginatedList
|
|
onRunScript={(script) => setSelectedScript(script)}
|
|
isUpdating={isUpdating}
|
|
teamId={teamId}
|
|
isFreeTier={isFreeTier}
|
|
scriptCount={scripts.length}
|
|
setScriptForDetails={setScriptForDetails}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
const platforms =
|
|
selectedScript.name.indexOf(".ps1") > 0 ? "Windows" : "macOS and Linux";
|
|
return (
|
|
<div className={`${baseClass}__script-schedule`}>
|
|
<p>
|
|
<b>{selectedScript.name}</b> will run on compatible hosts ({platforms}
|
|
).
|
|
</p>
|
|
<div className={`${baseClass}__script-run-mode-form`}>
|
|
<div className="form-field">
|
|
<div className="form-field__label">Schedule</div>
|
|
<Radio
|
|
className={`${baseClass}__radio-input`}
|
|
label="Run now"
|
|
id="run-now-batch-scripts-radio-btn"
|
|
checked={runMode === "run_now"}
|
|
value="Run now"
|
|
name="run-mode"
|
|
onChange={() => onChangeRunMode("run_now")}
|
|
/>
|
|
<Radio
|
|
className={`${baseClass}__radio-input`}
|
|
label="Schedule for later"
|
|
id="custom-target-radio-btn"
|
|
checked={runMode === "schedule"}
|
|
value="Custom"
|
|
name="target-type"
|
|
onChange={() => onChangeRunMode("schedule")}
|
|
/>
|
|
</div>
|
|
{runMode === "schedule" && (
|
|
<div className={`${baseClass}__script-schedule-form`}>
|
|
<span className="date-time-inputs">
|
|
<InputField
|
|
onChange={onInputChange}
|
|
value={batchRunDate}
|
|
label="Date (UTC)"
|
|
name="date"
|
|
parseTarget
|
|
helpText='YYYY-MM-DD format (e.g., "2024-07-01").'
|
|
error={formValidation.date?.message}
|
|
/>
|
|
<InputField
|
|
onChange={onInputChange}
|
|
value={batchRunTime}
|
|
label="Time (UTC)"
|
|
name="time"
|
|
parseTarget
|
|
helpText='HH:MM 24-hour format (e.g., "13:37").'
|
|
error={formValidation.time?.message}
|
|
tooltip={currentTimeUTC}
|
|
/>
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const classes = classnames(baseClass, {
|
|
[`${baseClass}__hide-main`]: !!scriptForDetails,
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<Modal
|
|
title="Run script"
|
|
onExit={onCancel}
|
|
onEnter={onCancel}
|
|
className={classes}
|
|
disableClosingModal={isUpdating}
|
|
>
|
|
<>
|
|
{renderModalContent()}
|
|
{!selectedScript && !scriptForDetails && (
|
|
<div className="modal-cta-wrap">
|
|
<Button disabled={isUpdating} onClick={onCancel}>
|
|
Done
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{selectedScript && (
|
|
<div className="modal-cta-wrap">
|
|
<TooltipWrapper
|
|
tipContent="Enter a date and time to schedule this script."
|
|
underline={false}
|
|
position="top"
|
|
disableTooltip={formValidation.isValid}
|
|
showArrow
|
|
>
|
|
<Button
|
|
disabled={isUpdating || !formValidation.isValid}
|
|
onClick={() => onRunScriptBatch(selectedScript)}
|
|
isLoading={isUpdating}
|
|
>
|
|
Run
|
|
</Button>
|
|
</TooltipWrapper>
|
|
<Button
|
|
disabled={isUpdating}
|
|
variant="inverse"
|
|
onClick={() => {
|
|
setSelectedScript(undefined);
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
</Modal>
|
|
{!!scriptForDetails && !selectedScript && (
|
|
<ScriptDetailsModal
|
|
onCancel={() => setScriptForDetails(undefined)}
|
|
selectedScriptDetails={scriptForDetails}
|
|
suppressSecondaryActions
|
|
customPrimaryButtons={
|
|
<div className="modal-cta-wrap">
|
|
<Button
|
|
onClick={() => {
|
|
setScriptForDetails(undefined);
|
|
setSelectedScript(scriptForDetails);
|
|
}}
|
|
isLoading={isUpdating}
|
|
>
|
|
Run
|
|
</Button>
|
|
<Button
|
|
onClick={() => setScriptForDetails(undefined)}
|
|
variant="inverse"
|
|
>
|
|
Go back
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default RunScriptBatchModal;
|