mirror of
https://github.com/fleetdm/fleet
synced 2026-05-06 14:58:33 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #32632 # Details This PR updates the Script Library page in the following ways: * When no scripts are uploaded for a team, it shows the "Add script" UI with a button that opens a new "Add Script" modal * When scripts are uploaded, the "Add script" button is instead added to the header of the scripts list, and clicking it opens that modal # 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 - [ ] Added/updated automated tests working on this - [X] QA'd all new/changed functionality manually - [X] Test empty state: go to controls/scripts/library for a team with no scripts. Clicking "upload" button in empty state should open the add script modal. - [X] In the modal, select a .ps1 script. Should not see additional text. - [X] Close modal without uploading. Re-open. File field should be cleared & upload button visible again. - [X] Select a .sh script. Should see additional text about macOS and Linux. - [X] Add script. Make sure script saves and modal closes. - [X] Once script has been added, make sure empty state is gone and "Add script" button is at the top of the list. - [X] Go to /controls/os-settings/custom-settings for a team with no profiles uploaded. Make sure empty state text styles match the empty state for script uploads. - [X] Open modal to add profile. Make sure upload text styles match the script upload modal. - [X] Enable GitOps mode. Go to controls/scripts/library for a team with scripts added. Make sure new "Add script" button is disabled w/ standard tooltip in GitOps mode. Scripts empty state: <img width="697" height="352" alt="image" src="https://github.com/user-attachments/assets/32f0f246-bddb-4bb7-bc39-48d9978de9fa" /> Scripts uploader: <img width="745" height="590" alt="image" src="https://github.com/user-attachments/assets/f82414e2-9318-4543-b5ca-41e759662587" /> Scripts uploader with .sh <img width="750" height="539" alt="image" src="https://github.com/user-attachments/assets/0b989067-921a-4d18-93ed-09aac90fc9cb" /> Scripts table: <img width="686" height="256" alt="image" src="https://github.com/user-attachments/assets/848f1b56-6e9e-48d4-9a03-6fdf5427301e" /> Profiles empty state: <img width="700" height="377" alt="image" src="https://github.com/user-attachments/assets/8f92bcd9-2215-41f6-a540-4774f7e9542b" /> Profiles uploader: <img width="707" height="682" alt="image" src="https://github.com/user-attachments/assets/eef216af-3447-48e7-882a-e42e888e1c17" />
246 lines
6.4 KiB
TypeScript
246 lines
6.4 KiB
TypeScript
import React, { useContext, useState } from "react";
|
|
import { useQuery } from "react-query";
|
|
|
|
import classnames from "classnames";
|
|
|
|
import { NotificationContext } from "context/notification";
|
|
import { AppContext } from "context/app";
|
|
import { getPathWithQueryParams } from "utilities/url";
|
|
import scriptAPI from "services/entities/scripts";
|
|
|
|
import Button from "components/buttons/Button";
|
|
import CustomLink from "components/CustomLink";
|
|
import DataError from "components/DataError";
|
|
import Editor from "components/Editor";
|
|
import Modal from "components/Modal";
|
|
import ModalFooter from "components/ModalFooter";
|
|
import Spinner from "components/Spinner";
|
|
import paths from "router/paths";
|
|
|
|
import { ScriptContent } from "interfaces/script";
|
|
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
|
|
import { getErrorMessage } from "../ScriptUploadModal/helpers";
|
|
|
|
const baseClass = "edit-script-modal";
|
|
|
|
interface IWarningModal {
|
|
onExit: () => void;
|
|
onSave: () => void;
|
|
scriptName: string;
|
|
isSubmitting: boolean;
|
|
}
|
|
const WarningModal = ({
|
|
onExit,
|
|
onSave,
|
|
scriptName,
|
|
isSubmitting,
|
|
}: IWarningModal) => {
|
|
return (
|
|
<Modal
|
|
className={`${baseClass}__warning`}
|
|
title="Save changes?"
|
|
onExit={onExit}
|
|
>
|
|
<>
|
|
<p>
|
|
The changes you are making will cancel any pending script runs for{" "}
|
|
<b>{scriptName}</b>.<br />
|
|
<br />
|
|
If this script is currently running on a host, it will complete, but
|
|
results won't appear in Fleet. <br />
|
|
<br />
|
|
You cannot undo this action.
|
|
</p>
|
|
<div className="modal-cta-wrap">
|
|
<Button
|
|
type="button"
|
|
onClick={onSave}
|
|
className="save-loading"
|
|
isLoading={isSubmitting}
|
|
>
|
|
Save
|
|
</Button>
|
|
<Button onClick={onExit} variant="inverse">
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
interface IEditScriptModal {
|
|
onExit: () => void;
|
|
scriptId: number;
|
|
scriptName: string;
|
|
}
|
|
|
|
const validate = (scriptContent: string) => {
|
|
if (scriptContent.trim() === "") {
|
|
return "Script cannot be empty";
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const EditScriptModal = ({
|
|
scriptId,
|
|
scriptName,
|
|
onExit,
|
|
}: IEditScriptModal) => {
|
|
const { renderFlash } = useContext(NotificationContext);
|
|
const { currentTeam } = useContext(AppContext);
|
|
|
|
// Editable script content
|
|
const [scriptFormData, setScriptFormData] = useState("");
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
|
|
const [showConfirmChanges, setShowConfirmChanges] = useState(false);
|
|
|
|
const {
|
|
data: curScriptContent,
|
|
error: isSelectedScriptContentError,
|
|
isLoading: isLoadingSelectedScriptContent,
|
|
} = useQuery<ScriptContent, Error>(
|
|
[scriptId],
|
|
() => scriptAPI.downloadScript(scriptId),
|
|
{
|
|
...DEFAULT_USE_QUERY_OPTIONS,
|
|
onSuccess: (curScriptContent_) => {
|
|
setScriptFormData(curScriptContent_);
|
|
},
|
|
}
|
|
);
|
|
|
|
const onChange = (value: string) => {
|
|
setScriptFormData(value);
|
|
const err = validate(value);
|
|
if (!err && !!formError) {
|
|
setFormError(validate(value));
|
|
}
|
|
};
|
|
|
|
const onBlur = () => {
|
|
setFormError(validate(scriptFormData));
|
|
};
|
|
|
|
const onSave = async () => {
|
|
const err = validate(scriptFormData);
|
|
setFormError(err);
|
|
if (err || isSubmitting) {
|
|
return;
|
|
}
|
|
// if contents have changed and not already showing the warning modal
|
|
if (curScriptContent !== scriptFormData && !showConfirmChanges) {
|
|
setShowConfirmChanges(true);
|
|
return;
|
|
}
|
|
// show warning modal and abort this call
|
|
try {
|
|
setIsSubmitting(true);
|
|
await scriptAPI.updateScript(scriptId, scriptFormData, scriptName);
|
|
renderFlash("success", "Successfully saved script.");
|
|
onExit();
|
|
} catch (e) {
|
|
renderFlash("error", getErrorMessage(e));
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
setShowConfirmChanges(false);
|
|
}
|
|
};
|
|
|
|
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
onSave();
|
|
};
|
|
|
|
const renderContent = () => {
|
|
if (isLoadingSelectedScriptContent) {
|
|
return <Spinner />;
|
|
}
|
|
|
|
if (isSelectedScriptContentError) {
|
|
return <DataError description="Close this modal and try again." />;
|
|
}
|
|
|
|
// Set editing mode based on the file extension.
|
|
const mode = scriptName.match(/\.sh$/) ? "sh" : "powershell";
|
|
|
|
return (
|
|
<>
|
|
<form onSubmit={onSubmit}>
|
|
<Editor
|
|
mode={mode}
|
|
error={formError}
|
|
label="Script"
|
|
onBlur={onBlur}
|
|
onChange={onChange}
|
|
value={scriptFormData}
|
|
/>
|
|
<div className="form-field__help-text">
|
|
To run this script on a host, go to the{" "}
|
|
<CustomLink
|
|
text="Hosts"
|
|
url={getPathWithQueryParams(paths.MANAGE_HOSTS, {
|
|
team_id: currentTeam?.id,
|
|
})}
|
|
/>{" "}
|
|
page and select a host.
|
|
<br />
|
|
To run the script across multiple hosts, add a policy automation on
|
|
the{" "}
|
|
<CustomLink
|
|
text="Policies"
|
|
url={getPathWithQueryParams(paths.MANAGE_POLICIES, {
|
|
team_id: currentTeam?.id,
|
|
})}
|
|
/>{" "}
|
|
page.
|
|
</div>
|
|
</form>
|
|
<ModalFooter
|
|
primaryButtons={
|
|
<>
|
|
<Button onClick={onExit} variant="inverse">
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={onSave}
|
|
isLoading={isSubmitting}
|
|
disabled={!!formError}
|
|
>
|
|
Save
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const classes = classnames(baseClass, {
|
|
[`${baseClass}__hide-main`]: !!showConfirmChanges,
|
|
});
|
|
return (
|
|
<>
|
|
<Modal
|
|
className={classes}
|
|
title={scriptName}
|
|
width="large"
|
|
onExit={onExit}
|
|
>
|
|
{renderContent()}
|
|
</Modal>
|
|
{!!showConfirmChanges && (
|
|
<WarningModal
|
|
onExit={() => setShowConfirmChanges(false)}
|
|
onSave={onSave}
|
|
scriptName={scriptName}
|
|
isSubmitting={isSubmitting}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default EditScriptModal;
|