UI: Update the save query modal with scheduling-related fields (#12741)

## Addresses #12646
### See issue for list of completed work
![Screenshot 2023-07-12 at 5 41 05
PM](https://github.com/fleetdm/fleet/assets/61553566/b4ece0c9-5df1-4320-9dce-1cd8c2758c6c)


### Also see PR #12713 **notes for review** on that PR for help manually
testing this work in lieu of the completed API.

- [x] Changes file added for user-visible changes in `changes/`
- [x] Manual QA for all new/changed functionality

---------

Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
This commit is contained in:
Jacob Shandling
2023-07-17 14:09:59 -07:00
committed by GitHub
co-authored by Jacob Shandling
parent 53f57c44db
commit 1d6870f0a7
14 changed files with 362 additions and 179 deletions
+1
View File
@@ -0,0 +1 @@
- Update the save query modal to include scheduling-related fields.
+1 -1
View File
@@ -77,7 +77,7 @@ export interface IModifyQueryRequestBody
// Delete a query by name
/** DELETE /api/v1/fleet/queries/{name} */
export interface IDeleteQueryRequestBody {
team_id?: number; // searches for a global query if ommitted
team_id?: number; // searches for a global query if omitted
}
// Delete a query by id
@@ -150,7 +150,7 @@ const ManageQueriesPage = ({
}
}, [location, filteredQueriesPath, setFilteredQueriesPath]);
const onCreateQueryClick = () => router.push(PATHS.NEW_QUERY);
const onCreateQueryClick = () => router.push(PATHS.NEW_QUERY(currentTeamId));
const toggleDeleteQueryModal = useCallback(() => {
setShowDeleteQueryModal(!showDeleteQueryModal);
+30 -14
View File
@@ -12,7 +12,11 @@ import statusAPI from "services/entities/status";
import { IHost, IHostResponse } from "interfaces/host";
import { ILabel } from "interfaces/label";
import { ITeam } from "interfaces/team";
import { IQueryFormData, IQuery, IStoredQueryResponse } from "interfaces/query";
import {
ICreateQueryRequestBody,
IGetQueryResponse,
ISchedulableQuery,
} from "interfaces/schedulable_query";
import { ITarget } from "interfaces/target";
import QuerySidePanel from "components/side_panels/QuerySidePanel";
@@ -23,12 +27,15 @@ import CustomLink from "components/CustomLink";
import QueryEditor from "pages/queries/QueryPage/screens/QueryEditor";
import RunQuery from "pages/queries/QueryPage/screens/RunQuery";
import useTeamIdParam from "hooks/useTeamIdParam";
interface IQueryPageProps {
router: InjectedRouter;
params: Params;
location: {
query: { host_ids: string };
pathname: string;
query: { host_ids: string; team_id?: string };
search: string;
};
}
@@ -37,9 +44,15 @@ const baseClass = "query-page";
const QueryPage = ({
router,
params: { id: paramsQueryId },
location: { query: URLQuerySearch },
location,
}: IQueryPageProps): JSX.Element => {
const queryId = paramsQueryId ? parseInt(paramsQueryId, 10) : null;
const { teamIdForApi: teamIdForQuery } = useTeamIdParam({
location,
router,
includeAllTeams: true,
includeNoTeam: false,
});
const handlePageError = useErrorHandler();
const {
@@ -78,13 +91,13 @@ const QueryPage = ({
isLoading: isStoredQueryLoading,
data: storedQuery,
error: storedQueryError,
} = useQuery<IStoredQueryResponse, Error, IQuery>(
} = useQuery<IGetQueryResponse, Error, ISchedulableQuery>(
["query", queryId],
() => queryAPI.load(queryId as number),
{
enabled: !!queryId,
refetchOnWindowFocus: false,
select: (data: IStoredQueryResponse) => data.query,
select: (data) => data.query,
onSuccess: (returnedQuery) => {
setLastEditedQueryId(returnedQuery.id);
setLastEditedQueryName(returnedQuery.name);
@@ -99,9 +112,9 @@ const QueryPage = ({
useQuery<IHostResponse, Error, IHost>(
"hostFromURL",
() =>
hostAPI.loadHostDetails(parseInt(URLQuerySearch.host_ids as string, 10)),
hostAPI.loadHostDetails(parseInt(location.query.host_ids as string, 10)),
{
enabled: !!URLQuerySearch.host_ids && !queryParamHostsAdded,
enabled: !!location.query.host_ids && !queryParamHostsAdded,
select: (data: IHostResponse) => data.host,
onSuccess: (host) => {
setTargetedHosts((prevHosts) =>
@@ -119,7 +132,9 @@ const QueryPage = ({
}
);
const { mutateAsync: createQuery } = useMutation((formData: IQueryFormData) =>
const {
mutateAsync: createQuery,
} = useMutation((formData: ICreateQueryRequestBody) =>
queryAPI.create(formData)
);
@@ -179,10 +194,11 @@ const QueryPage = ({
const goToQueryEditor = useCallback(() => setStep(QUERIES_PAGE_STEPS[1]), []);
const renderScreen = () => {
const step1Opts = {
const step1Props = {
router,
baseClass,
queryIdForEdit: queryId,
teamIdForQuery,
showOpenSchemaActionText,
storedQuery,
isStoredQueryLoading,
@@ -194,7 +210,7 @@ const QueryPage = ({
renderLiveQueryWarning,
};
const step2Opts = {
const step2Props = {
baseClass,
queryId,
selectedTargets,
@@ -211,7 +227,7 @@ const QueryPage = ({
setTargetsTotalCount,
};
const step3Opts = {
const step3Props = {
queryId,
selectedTargets,
storedQuery,
@@ -222,11 +238,11 @@ const QueryPage = ({
switch (step) {
case QUERIES_PAGE_STEPS[2]:
return <SelectTargets {...step2Opts} />;
return <SelectTargets {...step2Props} />;
case QUERIES_PAGE_STEPS[3]:
return <RunQuery {...step3Opts} />;
return <RunQuery {...step3Props} />;
default:
return <QueryEditor {...step1Opts} />;
return <QueryEditor {...step1Props} />;
}
};
@@ -1,135 +0,0 @@
import React, { useState, useEffect } from "react";
import { size } from "lodash";
import { IQueryFormData } from "interfaces/query";
import useDeepEffect from "hooks/useDeepEffect";
import Checkbox from "components/forms/fields/Checkbox";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import Button from "components/buttons/Button";
import Modal from "components/Modal";
export interface INewQueryModalProps {
baseClass: string;
queryValue: string;
isLoading: boolean;
onCreateQuery: (formData: IQueryFormData) => void;
setIsSaveModalOpen: (isOpen: boolean) => void;
backendValidators: { [key: string]: string };
}
const validateQueryName = (name: string) => {
const errors: { [key: string]: string } = {};
if (!name) {
errors.name = "Query name must be present";
}
const valid = !size(errors);
return { valid, errors };
};
const NewQueryModal = ({
baseClass,
queryValue,
isLoading,
onCreateQuery,
setIsSaveModalOpen,
backendValidators,
}: INewQueryModalProps): JSX.Element => {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [observerCanRun, setObserverCanRun] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string }>(
backendValidators
);
useDeepEffect(() => {
if (name) {
setErrors({});
}
}, [name]);
useEffect(() => {
setErrors(backendValidators);
}, [backendValidators]);
const handleUpdate = (evt: React.MouseEvent<HTMLFormElement>) => {
evt.preventDefault();
const { valid, errors: newErrors } = validateQueryName(name);
setErrors({
...errors,
...newErrors,
});
if (valid) {
onCreateQuery({
description,
name,
query: queryValue,
observer_can_run: observerCanRun,
});
}
};
return (
<Modal title={"Save query"} onExit={() => setIsSaveModalOpen(false)}>
<>
<form
onSubmit={handleUpdate}
className={`${baseClass}__save-modal-form`}
autoComplete="off"
>
<InputField
name="name"
onChange={(value: string) => setName(value)}
value={name}
error={errors.name}
inputClassName={`${baseClass}__query-save-modal-name`}
label="Name"
placeholder="What is your query called?"
autofocus
/>
<InputField
name="description"
onChange={(value: string) => setDescription(value)}
value={description}
inputClassName={`${baseClass}__query-save-modal-description`}
label="Description"
type="textarea"
placeholder="What information does your query reveal? (optional)"
/>
<Checkbox
name="observerCanRun"
onChange={setObserverCanRun}
value={observerCanRun}
wrapperClassName={`${baseClass}__query-save-modal-observer-can-run-wrapper`}
>
Observers can run
</Checkbox>
<p>
Users with the Observer role will be able to run this query on hosts
where they have access.
</p>
<div className="modal-cta-wrap">
<Button
type="submit"
variant="brand"
className="save-query-loading"
isLoading={isLoading}
>
Save query
</Button>
<Button onClick={() => setIsSaveModalOpen(false)} variant="inverse">
Cancel
</Button>
</div>
</form>
</>
</Modal>
);
};
export default NewQueryModal;
@@ -1 +0,0 @@
export { default } from "./NewQueryModal";
@@ -26,7 +26,7 @@ import Checkbox from "components/forms/fields/Checkbox";
import Spinner from "components/Spinner";
import Icon from "components/Icon/Icon";
import AutoSizeInputField from "components/forms/fields/AutoSizeInputField";
import NewQueryModal from "../NewQueryModal";
import SaveQueryModal from "../SaveQueryModal";
import InfoIcon from "../../../../../../assets/images/icon-info-purple-14x14@2x.png";
const baseClass = "query-form";
@@ -34,12 +34,13 @@ const baseClass = "query-form";
interface IQueryFormProps {
router: InjectedRouter;
queryIdForEdit: number | null;
teamIdForQuery?: number;
showOpenSchemaActionText: boolean;
storedQuery: IQuery | undefined;
isStoredQueryLoading: boolean;
isQuerySaving: boolean;
isQueryUpdating: boolean;
onCreateQuery: (formData: IQueryFormData) => void;
saveQuery: (formData: IQueryFormData) => void;
onOsqueryTableSelect: (tableName: string) => void;
goToSelectTargets: () => void;
onUpdate: (formData: IQueryFormData) => void;
@@ -63,12 +64,13 @@ const validateQuerySQL = (query: string) => {
const QueryForm = ({
router,
queryIdForEdit,
teamIdForQuery,
showOpenSchemaActionText,
storedQuery,
isStoredQueryLoading,
isQuerySaving,
isQueryUpdating,
onCreateQuery,
saveQuery,
onOsqueryTableSelect,
goToSelectTargets,
onUpdate,
@@ -104,7 +106,7 @@ const QueryForm = ({
const savedQueryMode = !!queryIdForEdit;
const [errors, setErrors] = useState<{ [key: string]: any }>({}); // string | null | undefined or boolean | undefined
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
const [showSaveQueryModal, setShowSaveQueryModal] = useState(false);
const [showQueryEditor, setShowQueryEditor] = useState(
isObserverPlus || isAnyTeamObserverPlus || false
);
@@ -142,6 +144,10 @@ const QueryForm = ({
storedQuery.author_id === currentUser.id
: isAnyTeamMaintainerOrTeamAdmin;
const toggleSaveQueryModal = () => {
setShowSaveQueryModal(!showSaveQueryModal);
};
const onLoad = (editor: IAceEditor) => {
editor.setOptions({
enableLinking: true,
@@ -260,7 +266,7 @@ const QueryForm = ({
if (valid) {
if (!savedQueryMode) {
setIsSaveModalOpen(true);
setShowSaveQueryModal(true);
} else {
onUpdate({
name: lastEditedQueryName,
@@ -565,12 +571,12 @@ const QueryForm = ({
</Button>
</div>
</form>
{isSaveModalOpen && (
<NewQueryModal
baseClass={baseClass}
{showSaveQueryModal && (
<SaveQueryModal
queryValue={lastEditedQueryBody}
onCreateQuery={onCreateQuery}
setIsSaveModalOpen={setIsSaveModalOpen}
teamIdForQuery={teamIdForQuery}
saveQuery={saveQuery}
toggleSaveQueryModal={toggleSaveQueryModal}
backendValidators={backendValidators}
isLoading={isQuerySaving}
/>
@@ -0,0 +1,259 @@
import React, { useState, useEffect, useCallback } from "react";
import { pull, size } from "lodash";
import useDeepEffect from "hooks/useDeepEffect";
import Checkbox from "components/forms/fields/Checkbox";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import Button from "components/buttons/Button";
import Modal from "components/Modal";
import {
FREQUENCY_DROPDOWN_OPTIONS,
LOGGING_TYPE_OPTIONS,
MIN_OSQUERY_VERSION_OPTIONS,
SCHEDULE_PLATFORM_DROPDOWN_OPTIONS,
} from "utilities/constants";
import RevealButton from "components/buttons/RevealButton";
import { IPlatformString } from "interfaces/platform";
import {
ICreateQueryRequestBody,
ISchedulableQuery,
QueryLoggingOption,
} from "interfaces/schedulable_query";
const baseClass = "save-query-modal";
export interface ISaveQueryModalProps {
queryValue: string;
teamIdForQuery?: number; // query will be global if omitted
isLoading: boolean;
saveQuery: (formData: ICreateQueryRequestBody) => void;
toggleSaveQueryModal: () => void;
backendValidators: { [key: string]: string };
existingQuery?: ISchedulableQuery;
}
const validateQueryName = (name: string) => {
const errors: { [key: string]: string } = {};
if (!name) {
errors.name = "Query name must be present";
}
const valid = !size(errors);
return { valid, errors };
};
const SaveQueryModal = ({
queryValue,
teamIdForQuery,
isLoading,
saveQuery,
toggleSaveQueryModal,
backendValidators,
existingQuery,
}: ISaveQueryModalProps): JSX.Element => {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [selectedFrequency, setSelectedFrequency] = useState(
existingQuery?.interval ?? 3600
);
const [
selectedPlatformOptions,
setSelectedPlatformOptions,
] = useState<IPlatformString>(existingQuery?.platform ?? "");
const [
selectedMinOsqueryVersionOptions,
setSelectedMinOsqueryVersionOptions,
] = useState(existingQuery?.min_osquery_version ?? "");
const [
selectedLoggingType,
setSelectedLoggingType,
] = useState<QueryLoggingOption>(existingQuery?.logging ?? "snapshot");
const [observerCanRun, setObserverCanRun] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string }>(
backendValidators
);
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const toggleAdvancedOptions = () => {
setShowAdvancedOptions(!showAdvancedOptions);
};
useDeepEffect(() => {
if (name) {
setErrors({});
}
}, [name]);
useEffect(() => {
setErrors(backendValidators);
}, [backendValidators]);
const onClickSaveQuery = (evt: React.MouseEvent<HTMLFormElement>) => {
evt.preventDefault();
const { valid, errors: newErrors } = validateQueryName(name);
setErrors({
...errors,
...newErrors,
});
if (valid) {
saveQuery({
// from modal fields
name,
description,
interval: selectedFrequency,
observer_can_run: observerCanRun,
platform: selectedPlatformOptions,
min_osquery_version: selectedMinOsqueryVersionOptions,
logging: selectedLoggingType,
// from previous New query page
query: queryValue,
// from doubly previous ManageQueriesPage
team_id: teamIdForQuery,
});
}
};
const onChangeSelectPlatformOptions = useCallback(
(values: string) => {
const valArray = values.split(",");
// Remove All if another OS is chosen
// else if Remove OS if All is chosen
if (valArray.indexOf("") === 0 && valArray.length > 1) {
// TODO - inmprove type safety of all 3 options
setSelectedPlatformOptions(
pull(valArray, "").join(",") as IPlatformString
);
} else if (valArray.length > 1 && valArray.indexOf("") > -1) {
setSelectedPlatformOptions("");
} else {
setSelectedPlatformOptions(values as IPlatformString);
}
},
[setSelectedPlatformOptions]
);
return (
<Modal title={"Save query"} onExit={toggleSaveQueryModal}>
<>
<form
onSubmit={onClickSaveQuery}
className={baseClass}
autoComplete="off"
>
<InputField
name="name"
onChange={(value: string) => setName(value)}
value={name}
error={errors.name}
inputClassName={`${baseClass}__name`}
label="Name"
placeholder="What is your query called?"
autofocus
/>
<InputField
name="description"
onChange={(value: string) => setDescription(value)}
value={description}
inputClassName={`${baseClass}__description`}
label="Description"
type="textarea"
placeholder="What information does your query reveal? (optional)"
/>
<div>
<Dropdown
searchable={false}
options={FREQUENCY_DROPDOWN_OPTIONS}
onChange={(value: number) => {
setSelectedFrequency(value);
}}
placeholder={"Every hour"}
value={selectedFrequency}
label="Frequency"
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`}
/>
<p className="help-text">
If automations are on, this is how often your query collects data.
</p>
</div>
<Checkbox
name="observerCanRun"
onChange={setObserverCanRun}
value={observerCanRun}
wrapperClassName={`${baseClass}__observer-can-run-wrapper`}
>
Observers can run
</Checkbox>
<p className="help-text">
Users with the Observer role will be able to run this query as a
live query.
</p>
<RevealButton
isShowing={showAdvancedOptions}
className={`${baseClass}__advanced-options-toggle`}
hideText={"Hide advanced options"}
showText={"Show advanced options"}
caretPosition={"after"}
onClick={toggleAdvancedOptions}
/>
{showAdvancedOptions && (
<>
<Dropdown
options={SCHEDULE_PLATFORM_DROPDOWN_OPTIONS}
placeholder="Select"
label="Platforms"
onChange={onChangeSelectPlatformOptions}
value={selectedPlatformOptions}
multi
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--platform`}
/>
<p className="help-text">
If automations are turned on, your query collects data on
compatible platforms.
<br />
If you want more control, override platforms.
</p>
<Dropdown
options={MIN_OSQUERY_VERSION_OPTIONS}
onChange={setSelectedMinOsqueryVersionOptions}
placeholder="Select"
value={selectedMinOsqueryVersionOptions}
label="Minimum osquery version"
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--osquer-vers`}
/>
<Dropdown
options={LOGGING_TYPE_OPTIONS}
onChange={setSelectedLoggingType}
placeholder="Select"
value={selectedLoggingType}
label="Logging"
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--logging`}
/>
</>
)}
<div className="modal-cta-wrap">
<Button
type="submit"
variant="brand"
className="save-query-loading"
isLoading={isLoading}
>
Save
</Button>
<Button onClick={toggleSaveQueryModal} variant="inverse">
Cancel
</Button>
</div>
</form>
</>
</Modal>
);
};
export default SaveQueryModal;
@@ -0,0 +1,32 @@
.save-query-modal {
.fleet-checkbox {
display: flex;
align-items: center;
}
.help-text {
margin-top: $pad-small;
margin-bottom: $pad-large;
font-weight: $regular;
font-size: 0.75rem;
color: $ui-fleet-black-75;
}
&__form-field {
&--frequency {
margin-bottom: 0;
}
&--platform {
margin-bottom: 0;
margin-top: $pad-large;
}
}
&__observer-can-run-wrapper {
margin-bottom: 0;
}
&__advanced-options-toggle {
font-weight: $xbold;
}
}
@@ -0,0 +1 @@
export { default } from "./SaveQueryModal";
@@ -8,6 +8,10 @@ import { AppContext } from "context/app";
import { QueryContext } from "context/query";
import { NotificationContext } from "context/notification";
import { IQueryFormData, IQuery } from "interfaces/query";
import {
ICreateQueryRequestBody,
ISchedulableQuery,
} from "interfaces/schedulable_query";
import PATHS from "router/paths";
import debounce from "utilities/debounce";
import deepDifference from "utilities/deep_difference";
@@ -19,15 +23,15 @@ interface IQueryEditorProps {
router: InjectedRouter;
baseClass: string;
queryIdForEdit: number | null;
teamIdForQuery?: number;
storedQuery: IQuery | undefined;
storedQueryError: Error | null;
showOpenSchemaActionText: boolean;
isStoredQueryLoading: boolean;
createQuery: UseMutateAsyncFunction<
{ query: IQuery },
ISchedulableQuery,
unknown,
IQueryFormData,
unknown
ICreateQueryRequestBody
>;
onOsqueryTableSelect: (tableName: string) => void;
goToSelectTargets: () => void;
@@ -39,6 +43,7 @@ const QueryEditor = ({
router,
baseClass,
queryIdForEdit,
teamIdForQuery,
storedQuery,
storedQueryError,
showOpenSchemaActionText,
@@ -77,10 +82,10 @@ const QueryEditor = ({
[key: string]: string;
}>({});
const onSaveQueryFormSubmit = debounce(async (formData: IQueryFormData) => {
const saveQuery = debounce(async (formData: ICreateQueryRequestBody) => {
setIsQuerySaving(true);
try {
const { query }: { query: IQuery } = await createQuery(formData);
const query = await createQuery(formData);
router.push(PATHS.EDIT_QUERY(query));
renderFlash("success", "Query created!");
setBackendValidators({});
@@ -149,12 +154,13 @@ const QueryEditor = ({
</div>
<QueryForm
router={router}
onCreateQuery={onSaveQueryFormSubmit}
saveQuery={saveQuery}
goToSelectTargets={goToSelectTargets}
onOsqueryTableSelect={onOsqueryTableSelect}
onUpdate={onUpdateQuery}
storedQuery={storedQuery}
queryIdForEdit={queryIdForEdit}
teamIdForQuery={teamIdForQuery}
isStoredQueryLoading={isStoredQueryLoading}
showOpenSchemaActionText={showOpenSchemaActionText}
onOpenSchemaSidebar={onOpenSchemaSidebar}
+2 -1
View File
@@ -109,7 +109,8 @@ export default {
MANAGE_POLICIES: `${URL_PREFIX}/policies/manage`,
NEW_LABEL: `${URL_PREFIX}/labels/new`,
NEW_POLICY: `${URL_PREFIX}/policies/new`,
NEW_QUERY: `${URL_PREFIX}/queries/new`,
NEW_QUERY: (teamId?: number) =>
`${URL_PREFIX}/queries/new${teamId ? `?team_id=${teamId}` : ""}`,
RESET_PASSWORD: `${URL_PREFIX}/login/reset`,
SETUP: `${URL_PREFIX}/setup`,
USER_SETTINGS: `${URL_PREFIX}/profile`,
+3 -10
View File
@@ -4,21 +4,14 @@ import endpoints from "utilities/endpoints";
import { IQueryFormData } from "interfaces/query";
import { ISelectedTargets } from "interfaces/target";
import { AxiosResponse } from "axios";
import { ICreateQueryRequestBody } from "interfaces/schedulable_query";
import { buildQueryStringFromParams } from "utilities/url";
// Mock API requests to be used in developing FE for #7765 in parallel with BE development
// import { sendRequest } from "services/mock_service/service/service";
export default {
create: ({ description, name, query, observer_can_run }: IQueryFormData) => {
create: (createQueryRequestBody: ICreateQueryRequestBody) => {
const { QUERIES } = endpoints;
return sendRequest("POST", QUERIES, {
description,
name,
query,
observer_can_run,
});
return sendRequest("POST", QUERIES, createQueryRequestBody);
},
destroy: (id: string | number) => {
const { QUERIES } = endpoints;
+4
View File
@@ -23,7 +23,11 @@ export const DEFAULT_GRAVATAR_LINK_DARK_FALLBACK =
"/assets/images/icon-avatar-default-dark-24x24%402x.png";
export const FREQUENCY_DROPDOWN_OPTIONS = [
{ value: 0, label: "Never" },
{ value: 300, label: "Every 5 minutes" },
{ value: 600, label: "Every 10 minutes" },
{ value: 900, label: "Every 15 minutes" },
{ value: 1800, label: "Every 30 minutes" },
{ value: 3600, label: "Every hour" },
{ value: 21600, label: "Every 6 hours" },
{ value: 43200, label: "Every 12 hours" },