diff --git a/assets/images/phone-home.svg b/assets/images/phone-home.svg deleted file mode 100644 index a0335a1113..0000000000 --- a/assets/images/phone-home.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/components/EmptyTable/_styles.scss b/frontend/components/EmptyTable/_styles.scss index c24d1c9543..15517d8d8f 100644 --- a/frontend/components/EmptyTable/_styles.scss +++ b/frontend/components/EmptyTable/_styles.scss @@ -57,7 +57,6 @@ &__container { align-self: center; justify-content: center; - margin: 0; margin-bottom: 20px; min-height: 155px; max-width: none; diff --git a/frontend/components/LiveQuery/SelectTargets.tsx b/frontend/components/LiveQuery/SelectTargets.tsx index c65a3e0e60..2052d13e14 100644 --- a/frontend/components/LiveQuery/SelectTargets.tsx +++ b/frontend/components/LiveQuery/SelectTargets.tsx @@ -48,7 +48,9 @@ interface ISelectTargetsProps { targetedTeams: ITeam[]; goToQueryEditor: () => void; goToRunQuery: () => void; - setSelectedTargets: React.Dispatch>; + setSelectedTargets: + | React.Dispatch> // Used for policies page level useState hook + | ((value: ITarget[]) => void); // Used for queries app level QueryContext setTargetedHosts: React.Dispatch>; setTargetedLabels: React.Dispatch>; setTargetedTeams: React.Dispatch>; diff --git a/frontend/components/LiveQuery/TargetsInput/_styles.scss b/frontend/components/LiveQuery/TargetsInput/_styles.scss index e2f14ce6e1..137525dd04 100644 --- a/frontend/components/LiveQuery/TargetsInput/_styles.scss +++ b/frontend/components/LiveQuery/TargetsInput/_styles.scss @@ -79,4 +79,7 @@ overflow: auto; } } + .input-icon-field__icon { + top: 34px; // Override styling to include label header + } } diff --git a/frontend/components/TooltipWrapper/TooltipWrapper.tsx b/frontend/components/TooltipWrapper/TooltipWrapper.tsx index 0b48e54b91..43e334495a 100644 --- a/frontend/components/TooltipWrapper/TooltipWrapper.tsx +++ b/frontend/components/TooltipWrapper/TooltipWrapper.tsx @@ -6,6 +6,7 @@ import * as DOMPurify from "dompurify"; interface ITooltipWrapperProps { children: string | JSX.Element; tipContent: string; + /** Default: bottom */ position?: "top" | "bottom"; isDelayed?: boolean; className?: string; diff --git a/frontend/components/icons/CollectingResults.tsx b/frontend/components/icons/CollectingResults.tsx new file mode 100644 index 0000000000..0df9824b3e --- /dev/null +++ b/frontend/components/icons/CollectingResults.tsx @@ -0,0 +1,254 @@ +import React from "react"; + +const CollectingResults = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CollectingResults; diff --git a/frontend/components/icons/index.ts b/frontend/components/icons/index.ts index cd18850643..36ac93a842 100644 --- a/frontend/components/icons/index.ts +++ b/frontend/components/icons/index.ts @@ -4,6 +4,7 @@ import ArrowInternalLink from "./ArrowInternalLink"; import CalendarCheck from "./CalendarCheck"; import Check from "./Check"; import Chevron from "./Chevron"; +import CollectingResults from "./CollectingResults"; import Columns from "./Columns"; import CriticalPolicy from "./CriticalPolicy"; import Disable from "./Disable"; @@ -84,6 +85,7 @@ export const ICON_MAP = { "calendar-check": CalendarCheck, chevron: Chevron, check: Check, + "collecting-results": CollectingResults, columns: Columns, "critical-policy": CriticalPolicy, disable: Disable, diff --git a/frontend/components/queries/queryResults/AwaitingResults/AwaitingResults.tsx b/frontend/components/queries/queryResults/AwaitingResults/AwaitingResults.tsx index ed2bf4953a..e807a83b6a 100644 --- a/frontend/components/queries/queryResults/AwaitingResults/AwaitingResults.tsx +++ b/frontend/components/queries/queryResults/AwaitingResults/AwaitingResults.tsx @@ -1,19 +1,18 @@ import React from "react"; -import PhoneHome from "../../../../../assets/images/phone-home.svg"; +import EmptyTable from "components/EmptyTable/EmptyTable"; const baseClass = "awaiting-results"; const AwaitingResults = () => { return ( - - - Phoning home... - - There are currently no results to your query. Please wait while we talk - to more hosts. - - + ); }; diff --git a/frontend/components/queries/queryResults/AwaitingResults/_styles.scss b/frontend/components/queries/queryResults/AwaitingResults/_styles.scss index 12463a3844..ba2b2dad9b 100644 --- a/frontend/components/queries/queryResults/AwaitingResults/_styles.scss +++ b/frontend/components/queries/queryResults/AwaitingResults/_styles.scss @@ -5,19 +5,4 @@ flex-direction: column; align-items: center; text-align: center; - - img { - margin-bottom: $pad-medium; - } - - &__title { - font-size: $small; - font-weight: $bold; - margin-bottom: $pad-small; - } - - &__description { - font-size: $x-small; - margin: 0; - } } diff --git a/frontend/context/query.tsx b/frontend/context/query.tsx index 992e152cad..e313a0a156 100644 --- a/frontend/context/query.tsx +++ b/frontend/context/query.tsx @@ -6,6 +6,7 @@ import { DEFAULT_QUERY } from "utilities/constants"; import { DEFAULT_OSQUERY_TABLE, IOsQueryTable } from "interfaces/osquery_table"; import { SelectedPlatformString } from "interfaces/platform"; import { QueryLoggingOption } from "interfaces/schedulable_query"; +import { DEFAULT_TARGETS, ITarget } from "interfaces/target"; type Props = { children: ReactNode; @@ -22,6 +23,7 @@ type InitialStateType = { lastEditedQueryPlatforms: SelectedPlatformString; lastEditedQueryMinOsqueryVersion: string; lastEditedQueryLoggingType: QueryLoggingOption; + selectedQueryTargets: ITarget[]; setLastEditedQueryId: (value: number | null) => void; setLastEditedQueryName: (value: string) => void; setLastEditedQueryDescription: (value: string) => void; @@ -32,6 +34,7 @@ type InitialStateType = { setLastEditedQueryMinOsqueryVersion: (value: string) => void; setLastEditedQueryLoggingType: (value: string) => void; setSelectedOsqueryTable: (tableName: string) => void; + setSelectedQueryTargets: (value: ITarget[]) => void; }; export type IQueryContext = InitialStateType; @@ -48,6 +51,7 @@ const initialState = { lastEditedQueryPlatforms: DEFAULT_QUERY.platform, lastEditedQueryMinOsqueryVersion: DEFAULT_QUERY.min_osquery_version, lastEditedQueryLoggingType: DEFAULT_QUERY.logging, + selectedQueryTargets: DEFAULT_TARGETS, setLastEditedQueryId: () => null, setLastEditedQueryName: () => null, setLastEditedQueryDescription: () => null, @@ -58,11 +62,13 @@ const initialState = { setLastEditedQueryMinOsqueryVersion: () => null, setLastEditedQueryLoggingType: () => null, setSelectedOsqueryTable: () => null, + setSelectedQueryTargets: () => null, }; const actions = { SET_SELECTED_OSQUERY_TABLE: "SET_SELECTED_OSQUERY_TABLE", SET_LAST_EDITED_QUERY_INFO: "SET_LAST_EDITED_QUERY_INFO", + SET_SELECTED_QUERY_TARGETS: "SET_SELECTED_QUERY_TARGETS", } as const; const reducer = (state: InitialStateType, action: any) => { @@ -114,6 +120,14 @@ const reducer = (state: InitialStateType, action: any) => { ? state.lastEditedQueryLoggingType : action.lastEditedQueryLoggingType, }; + case actions.SET_SELECTED_QUERY_TARGETS: + return { + ...state, + selectedQueryTargets: + typeof action.selectedQueryTargets === "undefined" + ? state.selectedQueryTargets + : action.selectedQueryTargets, + }; default: return state; } @@ -135,6 +149,7 @@ const QueryProvider = ({ children }: Props) => { lastEditedQueryPlatforms: state.lastEditedQueryPlatforms, lastEditedQueryMinOsqueryVersion: state.lastEditedQueryMinOsqueryVersion, lastEditedQueryLoggingType: state.lastEditedQueryLoggingType, + selectedQueryTargets: state.selectedQueryTargets, setLastEditedQueryId: (lastEditedQueryId: number | null) => { dispatch({ type: actions.SET_LAST_EDITED_QUERY_INFO, @@ -193,6 +208,12 @@ const QueryProvider = ({ children }: Props) => { lastEditedQueryLoggingType, }); }, + setSelectedQueryTargets: (selectedQueryTargets: ITarget[]) => { + dispatch({ + type: actions.SET_SELECTED_QUERY_TARGETS, + selectedQueryTargets, + }); + }, setSelectedOsqueryTable: (tableName: string) => { dispatch({ type: actions.SET_SELECTED_OSQUERY_TABLE, tableName }); }, diff --git a/frontend/interfaces/target.ts b/frontend/interfaces/target.ts index 7526719212..873d7907f3 100644 --- a/frontend/interfaces/target.ts +++ b/frontend/interfaces/target.ts @@ -49,3 +49,6 @@ export interface IPackTargets { label_ids: (number | string)[]; team_ids: (number | string)[]; } + +// TODO: Also use for testing +export const DEFAULT_TARGETS: ITarget[] = []; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 0d1a0b6b46..132dd5b502 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -39,7 +39,11 @@ import MainContent from "components/MainContent"; import InfoBanner from "components/InfoBanner"; import BackLink from "components/BackLink"; -import { normalizeEmptyValues, wrapFleetHelper } from "utilities/helpers"; +import { + normalizeEmptyValues, + wrapFleetHelper, + TAGGED_TEMPLATES, +} from "utilities/helpers"; import permissions from "utilities/permissions"; import HostSummaryCard from "../cards/HostSummary"; @@ -99,12 +103,6 @@ interface IHostDetailsSubNavItem { pathname: string; } -const TAGGED_TEMPLATES = { - queryByHostRoute: (hostId: number | undefined | null) => { - return `${hostId ? `?host_ids=${hostId}` : ""}`; - }, -}; - const HostDetailsPage = ({ route, router, diff --git a/frontend/pages/policies/PolicyPage/PolicyPage.tsx b/frontend/pages/policies/PolicyPage/PolicyPage.tsx index ef3fc1eb4c..5d10551229 100644 --- a/frontend/pages/policies/PolicyPage/PolicyPage.tsx +++ b/frontend/pages/policies/PolicyPage/PolicyPage.tsx @@ -19,7 +19,7 @@ import globalPoliciesAPI from "services/entities/global_policies"; import teamPoliciesAPI from "services/entities/team_policies"; import hostAPI from "services/entities/hosts"; import statusAPI from "services/entities/status"; -import { QUERIES_PAGE_STEPS } from "utilities/constants"; +import { LIVE_POLICY_STEPS } from "utilities/constants"; import QuerySidePanel from "components/side_panels/QuerySidePanel"; import QueryEditor from "pages/policies/PolicyPage/screens/QueryEditor"; @@ -127,7 +127,7 @@ const PolicyPage = ({ }; }, []); - const [step, setStep] = useState(QUERIES_PAGE_STEPS[1]); + const [step, setStep] = useState(LIVE_POLICY_STEPS[1]); const [selectedTargets, setSelectedTargets] = useState([]); const [targetedHosts, setTargetedHosts] = useState([]); const [targetedLabels, setTargetedLabels] = useState([]); @@ -260,7 +260,7 @@ const PolicyPage = ({ storedPolicyError, createPolicy, onOsqueryTableSelect, - goToSelectTargets: () => setStep(QUERIES_PAGE_STEPS[2]), + goToSelectTargets: () => setStep(LIVE_POLICY_STEPS[2]), onOpenSchemaSidebar, renderLiveQueryWarning, }; @@ -272,8 +272,8 @@ const PolicyPage = ({ targetedLabels, targetedTeams, targetsTotalCount, - goToQueryEditor: () => setStep(QUERIES_PAGE_STEPS[1]), - goToRunQuery: () => setStep(QUERIES_PAGE_STEPS[3]), + goToQueryEditor: () => setStep(LIVE_POLICY_STEPS[1]), + goToRunQuery: () => setStep(LIVE_POLICY_STEPS[3]), setSelectedTargets, setTargetedHosts, setTargetedLabels, @@ -285,21 +285,21 @@ const PolicyPage = ({ selectedTargets, storedPolicy, setSelectedTargets, - goToQueryEditor: () => setStep(QUERIES_PAGE_STEPS[1]), + goToQueryEditor: () => setStep(LIVE_POLICY_STEPS[1]), targetsTotalCount, }; switch (step) { - case QUERIES_PAGE_STEPS[2]: + case LIVE_POLICY_STEPS[2]: return ; - case QUERIES_PAGE_STEPS[3]: + case LIVE_POLICY_STEPS[3]: return ; default: return ; } }; - const isFirstStep = step === QUERIES_PAGE_STEPS[1]; + const isFirstStep = step === LIVE_POLICY_STEPS[1]; const showSidebar = isFirstStep && isSidebarOpen && diff --git a/frontend/pages/policies/PolicyPage/_styles.scss b/frontend/pages/policies/PolicyPage/_styles.scss index 262b83b515..1eb28ded67 100644 --- a/frontend/pages/policies/PolicyPage/_styles.scss +++ b/frontend/pages/policies/PolicyPage/_styles.scss @@ -34,33 +34,6 @@ } } - &__observer-query-details { - padding: 0 2rem; - - h1 { - margin: $pad-large 0; - font-size: $large; - } - - p { - margin-bottom: $pad-small; - } - - .sql-button { - color: $core-vibrant-blue; - font-weight: $bold; - font-size: $x-small; - } - } - - &__query-preview { - margin-top: 15px; - - .fleet-ace__label { - display: none; - } - } - .ace_content { min-height: 500px !important; } @@ -177,9 +150,4 @@ margin-bottom: 0; } } - .targets-input { - .input-icon-field__icon { - top: 34px; // Override styling to include label header - } - } } diff --git a/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx b/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx index 89e46d024a..5e42e673de 100644 --- a/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx +++ b/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx @@ -173,7 +173,7 @@ const QueryEditor = ({ return null; } - // Function instead of constant eliminates race condition with filteredSoftwarePath + // Function instead of constant eliminates race condition with filteredPoliciesPath const backToPoliciesPath = () => { return filteredPoliciesPath || PATHS.MANAGE_POLICIES; }; diff --git a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx index f24c17af24..10b4611433 100644 --- a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx +++ b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx @@ -152,7 +152,7 @@ const generateTableHeaders = ({ )} > } - path={PATHS.EDIT_QUERY( + path={PATHS.QUERY( cellProps.row.original.id, cellProps.row.original.team_id ?? undefined )} diff --git a/frontend/pages/queries/QueryPage/index.ts b/frontend/pages/queries/QueryPage/index.ts deleted file mode 100644 index 8d00fa0475..0000000000 --- a/frontend/pages/queries/QueryPage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./QueryPage"; diff --git a/frontend/pages/queries/QueryPage/screens/QueryEditor.tsx b/frontend/pages/queries/QueryPage/screens/QueryEditor.tsx deleted file mode 100644 index 7b94956efd..0000000000 --- a/frontend/pages/queries/QueryPage/screens/QueryEditor.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import React, { useContext, useEffect, useState } from "react"; - -import { InjectedRouter } from "react-router/lib/Router"; -import { UseMutateAsyncFunction } from "react-query"; - -import queryAPI from "services/entities/queries"; -import { AppContext } from "context/app"; -import { QueryContext } from "context/query"; -import { NotificationContext } from "context/notification"; -import { - ICreateQueryRequestBody, - ISchedulableQuery, -} from "interfaces/schedulable_query"; -import PATHS from "router/paths"; -import debounce from "utilities/debounce"; -import deepDifference from "utilities/deep_difference"; - -import BackLink from "components/BackLink"; -import QueryForm from "pages/queries/QueryPage/components/QueryForm"; - -interface IQueryEditorProps { - router: InjectedRouter; - baseClass: string; - queryIdForEdit: number | null; - teamNameForQuery?: string; - apiTeamIdForQuery?: number; - storedQuery: ISchedulableQuery | undefined; - storedQueryError: Error | null; - showOpenSchemaActionText: boolean; - isStoredQueryLoading: boolean; - onOsqueryTableSelect: (tableName: string) => void; - goToSelectTargets: () => void; - onOpenSchemaSidebar: () => void; - renderLiveQueryWarning: () => JSX.Element | null; -} - -const QueryEditor = ({ - router, - baseClass, - queryIdForEdit, - teamNameForQuery, - apiTeamIdForQuery, - storedQuery, - storedQueryError, - showOpenSchemaActionText, - isStoredQueryLoading, - onOsqueryTableSelect, - goToSelectTargets, - onOpenSchemaSidebar, - renderLiveQueryWarning, -}: IQueryEditorProps): JSX.Element | null => { - const { currentUser, filteredQueriesPath } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); - - // Note: The QueryContext values should always be used for any mutable query data such as query name - // The storedQuery prop should only be used to access immutable metadata such as author id - const { - lastEditedQueryName, - lastEditedQueryDescription, - lastEditedQueryBody, - lastEditedQueryObserverCanRun, - lastEditedQueryFrequency, - lastEditedQueryLoggingType, - lastEditedQueryPlatforms, - lastEditedQueryMinOsqueryVersion, - } = useContext(QueryContext); - - const [isQuerySaving, setIsQuerySaving] = useState(false); - const [isQueryUpdating, setIsQueryUpdating] = useState(false); - - useEffect(() => { - if (storedQueryError) { - renderFlash( - "error", - "Something went wrong retrieving your query. Please try again." - ); - } - }, []); - - const [backendValidators, setBackendValidators] = useState<{ - [key: string]: string; - }>({}); - - const saveQuery = debounce(async (formData: ICreateQueryRequestBody) => { - setIsQuerySaving(true); - try { - const { query } = await queryAPI.create(formData); - router.push(PATHS.EDIT_QUERY(query.id)); - renderFlash("success", "Query created!"); - setBackendValidators({}); - } catch (createError: any) { - if (createError.data.errors[0].reason.includes("already exists")) { - const teamErrorText = - teamNameForQuery && apiTeamIdForQuery !== 0 - ? `the ${teamNameForQuery} team` - : "all teams"; - setBackendValidators({ - name: `A query with that name already exists for ${teamErrorText}.`, - }); - } else { - renderFlash( - "error", - "Something went wrong creating your query. Please try again." - ); - setBackendValidators({}); - } - } finally { - setIsQuerySaving(false); - } - }); - - const onUpdateQuery = async (formData: ICreateQueryRequestBody) => { - if (!queryIdForEdit) { - return false; - } - - setIsQueryUpdating(true); - - const updatedQuery = deepDifference(formData, { - lastEditedQueryName, - lastEditedQueryDescription, - lastEditedQueryBody, - lastEditedQueryObserverCanRun, - lastEditedQueryFrequency, - lastEditedQueryPlatforms, - lastEditedQueryLoggingType, - lastEditedQueryMinOsqueryVersion, - }); - - try { - await queryAPI.update(queryIdForEdit, updatedQuery); - renderFlash("success", "Query updated!"); - } catch (updateError: any) { - console.error(updateError); - if (updateError.data.errors[0].reason.includes("Duplicate")) { - renderFlash("error", "A query with this name already exists."); - } else { - renderFlash( - "error", - "Something went wrong updating your query. Please try again." - ); - } - } - - setIsQueryUpdating(false); - - return false; - }; - - if (!currentUser) { - return null; - } - - // Function instead of constant eliminates race condition with filteredSoftwarePath - const backToQueriesPath = () => { - return filteredQueriesPath || PATHS.MANAGE_QUERIES; - }; - - return ( - - - - - - - ); -}; - -export default QueryEditor; diff --git a/frontend/pages/queries/QueryPage/screens/test.js b/frontend/pages/queries/QueryPage/screens/test.js deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx new file mode 100644 index 0000000000..be52bd6ad3 --- /dev/null +++ b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx @@ -0,0 +1,241 @@ +import React, { useContext, useEffect } from "react"; +import { useQuery } from "react-query"; +import { InjectedRouter, Params } from "react-router/lib/Router"; +import { useErrorHandler } from "react-error-boundary"; + +import PATHS from "router/paths"; +import { AppContext } from "context/app"; +import { QueryContext } from "context/query"; +import useTeamIdParam from "hooks/useTeamIdParam"; + +import { + IGetQueryResponse, + ISchedulableQuery, +} from "interfaces/schedulable_query"; + +import queryAPI from "services/entities/queries"; + +import Spinner from "components/Spinner/Spinner"; +import Button from "components/buttons/Button"; +import BackLink from "components/BackLink"; +import MainContent from "components/MainContent"; +import TooltipWrapper from "components/TooltipWrapper/TooltipWrapper"; +import QueryAutomationsStatusIndicator from "pages/queries/ManageQueriesPage/components/QueryAutomationsStatusIndicator/QueryAutomationsStatusIndicator"; +import DataError from "components/DataError/DataError"; +import LogDestinationIndicator from "components/LogDestinationIndicator/LogDestinationIndicator"; +import CachedDetails from "../components/CachedDetails/CachedDetails"; +import NoResults from "../components/NoResults/NoResults"; + +interface IQueryDetailsPageProps { + router: InjectedRouter; // v3 + params: Params; + location: { + pathname: string; + query: { team_id?: string }; + search: string; + }; +} + +const baseClass = "query-details-page"; + +const QueryDetailsPage = ({ + router, + params: { id: paramsQueryId }, + location, +}: IQueryDetailsPageProps): JSX.Element => { + const queryId = paramsQueryId ? parseInt(paramsQueryId, 10) : null; + + const { + currentTeamName: teamNameForQuery, + teamIdForApi: apiTeamIdForQuery, + } = useTeamIdParam({ + location, + router, + includeAllTeams: true, + includeNoTeam: false, + }); + + const handlePageError = useErrorHandler(); + const { + isGlobalAdmin, + isGlobalMaintainer, + isAnyTeamMaintainerOrTeamAdmin, + isObserverPlus, + isAnyTeamObserverPlus, + config, + filteredQueriesPath, + } = useContext(AppContext); + const { + lastEditedQueryName, + lastEditedQueryDescription, + lastEditedQueryObserverCanRun, + setLastEditedQueryId, + setLastEditedQueryName, + setLastEditedQueryDescription, + setLastEditedQueryBody, + setLastEditedQueryObserverCanRun, + setLastEditedQueryFrequency, + setLastEditedQueryLoggingType, + setLastEditedQueryMinOsqueryVersion, + setLastEditedQueryPlatforms, + } = useContext(QueryContext); + + // Title that shows up on browser tabs (e.g., Query details | Discover TLS certificates | Fleet for osquery) + document.title = `Query details | ${lastEditedQueryName} | Fleet for osquery`; + + // disabled on page load so we can control the number of renders + // else it will re-populate the context on occasion + const { + isLoading: isStoredQueryLoading, + data: storedQuery, + error: storedQueryError, + } = useQuery( + ["query", queryId], + () => queryAPI.load(queryId as number), + { + enabled: !!queryId, + refetchOnWindowFocus: false, + select: (data) => data.query, + onSuccess: (returnedQuery) => { + setLastEditedQueryId(returnedQuery.id); + setLastEditedQueryName(returnedQuery.name); + setLastEditedQueryDescription(returnedQuery.description); + setLastEditedQueryBody(returnedQuery.query); + setLastEditedQueryObserverCanRun(returnedQuery.observer_can_run); + setLastEditedQueryFrequency(returnedQuery.interval); + setLastEditedQueryPlatforms(returnedQuery.platform); + setLastEditedQueryLoggingType(returnedQuery.logging); + setLastEditedQueryMinOsqueryVersion(returnedQuery.min_osquery_version); + }, + onError: (error) => handlePageError(error), + } + ); + + const isLoading = isStoredQueryLoading; // TODO: Add || isCachedResultsLoading for new API response + const isApiError = storedQueryError || true; // TODO: Add || isCachedResultsError for new API response + + const renderHeader = () => { + const canEditQuery = + isGlobalAdmin || isGlobalMaintainer || isAnyTeamMaintainerOrTeamAdmin; + + // Function instead of constant eliminates race condition with filteredQueriesPath + const backToQueriesPath = () => { + return filteredQueriesPath || PATHS.MANAGE_QUERIES; + }; + + return ( + <> + + + + {!isLoading && !isApiError && ( + + + + {lastEditedQueryName} + + + {lastEditedQueryDescription} + + + + { + queryId && router.push(PATHS.EDIT_QUERY(queryId)); + }} + className={`${baseClass}__manage-automations button`} + variant="brand" + > + {canEditQuery ? "Edit query" : "More details"} + + {(lastEditedQueryObserverCanRun || + isObserverPlus || + isAnyTeamObserverPlus || + canEditQuery) && ( + + { + queryId && router.push(PATHS.LIVE_QUERY(queryId)); + }} + > + Live query + + + )} + + + )} + {!isLoading && !isApiError && ( + + + + Automations: + + + + + Log destination:{" "} + + + + )} + > + ); + }; + + const renderReport = () => { + const disabledCachingGlobally = true; // TODO: Update accordingly to config?.server_settings.query_reports_disabled + const discardDataEnabled = true; // TODO: Update accordingly to storedQuery?.discard_data + const loggingSnapshot = storedQuery?.logging === "snapshot"; + const disabledCaching = + disabledCachingGlobally || discardDataEnabled || !loggingSnapshot; + const emptyCache = true; // TODO: Update with API response + const errorsOnly = true; // TODO: Update with API response + + // Loading state + if (isLoading) { + return ; + } + + // Error state + if (isApiError) { + return ; + } + + // Empty state with varying messages explaining why there's no results + if (emptyCache) { + return ( + + ); + } + return ; // TODO: Everything related to new APIs including surfacing errorsOnly + }; + + return ( + + + {renderHeader()} + {renderReport()} + + + ); +}; + +export default QueryDetailsPage; diff --git a/frontend/pages/queries/details/QueryDetailsPage/_styles.scss b/frontend/pages/queries/details/QueryDetailsPage/_styles.scss new file mode 100644 index 0000000000..9b0a5a0a25 --- /dev/null +++ b/frontend/pages/queries/details/QueryDetailsPage/_styles.scss @@ -0,0 +1,65 @@ +.query-details-page { + &__title-bar { + display: flex; + justify-content: space-between; + margin-top: $pad-large; + + .name-description { + display: flex; + flex-direction: column; + gap: $pad-small; + } + } + + &__action-button-container { + display: flex; + justify-content: flex-end; + min-width: 266px; + gap: $pad-medium; + } + + &__query-name { + margin-top: 0; + font-size: $large; + } + + &__query-description { + margin-top: 0; + margin-bottom: $pad-small; + font-size: $x-small; + } + + &__settings { + display: flex; + gap: $pad-large; + font-size: $x-small; + } + + &__automations, + &__log-destination { + display: flex; + gap: $pad-small; + } + + .empty-table__inner { + .component__tooltip-wrapper__tip-text { + text-align: left; + width: 320px; + } + + ul { + color: $core-white; + + li { + &::before { + content: "•"; + color: $core-white; + } + } + } + } + + .data-error { + padding-top: $pad-xxxlarge; + } +} diff --git a/frontend/pages/queries/details/QueryDetailsPage/index.ts b/frontend/pages/queries/details/QueryDetailsPage/index.ts new file mode 100644 index 0000000000..9bb526e7b5 --- /dev/null +++ b/frontend/pages/queries/details/QueryDetailsPage/index.ts @@ -0,0 +1 @@ +export { default } from "./QueryDetailsPage"; diff --git a/frontend/pages/queries/details/components/CachedDetails/CachedDetails.tsx b/frontend/pages/queries/details/components/CachedDetails/CachedDetails.tsx new file mode 100644 index 0000000000..ab5255e3f0 --- /dev/null +++ b/frontend/pages/queries/details/components/CachedDetails/CachedDetails.tsx @@ -0,0 +1,14 @@ +import React from "react"; + +// TODO: This whole section +// interface ICachedDetailsProps { +// +// } + +const baseClass = "cached-details"; + +const CachedDetails = (): JSX.Element => { + return TODO; +}; + +export default CachedDetails; diff --git a/frontend/pages/queries/details/components/CachedDetails/index.ts b/frontend/pages/queries/details/components/CachedDetails/index.ts new file mode 100644 index 0000000000..b50b73552b --- /dev/null +++ b/frontend/pages/queries/details/components/CachedDetails/index.ts @@ -0,0 +1 @@ +export { default } from "./CachedDetails"; diff --git a/frontend/pages/queries/details/components/NoResults/NoResults.tsx b/frontend/pages/queries/details/components/NoResults/NoResults.tsx new file mode 100644 index 0000000000..7897facb23 --- /dev/null +++ b/frontend/pages/queries/details/components/NoResults/NoResults.tsx @@ -0,0 +1,116 @@ +import React from "react"; + +import differenceInSeconds from "date-fns/differenceInSeconds"; +import formatDistance from "date-fns/formatDistance"; +import add from "date-fns/add"; + +import TooltipWrapper from "components/TooltipWrapper/TooltipWrapper"; +import EmptyTable from "components/EmptyTable/EmptyTable"; + +interface INoResultsProps { + queryInterval?: number; + queryUpdatedAt?: string; + disabledCaching: boolean; + disabledCachingGlobally: boolean; + discardDataEnabled: boolean; + loggingSnapshot: boolean; + errorsOnly: boolean; +} + +const baseClass = "no-results"; + +const NoResults = ({ + queryInterval, + queryUpdatedAt, + disabledCaching, + disabledCachingGlobally, + discardDataEnabled, + loggingSnapshot, + errorsOnly, +}: INoResultsProps): JSX.Element => { + // Returns how many seconds it takes to expect a cached update + const secondsCheckbackTime = () => { + const secondsSinceUpdate = queryUpdatedAt + ? differenceInSeconds(new Date(), new Date(queryUpdatedAt)) + : 0; + const secondsUpdateWaittime = (queryInterval || 0) + 60; + return secondsUpdateWaittime - secondsSinceUpdate; + }; + + // Update status of collecting cached results + const collectingResults = secondsCheckbackTime() > 0; + + // Converts seconds takes to update to human readable format + const readableCheckbackTime = formatDistance( + add(new Date(), { seconds: secondsCheckbackTime() }), + new Date() + ); + + // Collecting results state + if (collectingResults) { + const collectingResultsInfo = () => + `Fleet is collecting query results. Check back in about ${readableCheckbackTime}.`; + + return ( + + ); + } + + const noResultsInfo = () => { + if (!queryInterval) { + return ( + <> + This query does not collect data on a schedule. Add a{" "} + frequency or run this as a live query to see results. + > + ); + } + if (disabledCaching) { + const tipContent = () => { + if (disabledCachingGlobally) { + return "The following setting prevents saving this query's results in Fleet:Query reports are globally disabled in organization settings."; + } + if (discardDataEnabled) { + return "The following setting prevents saving this query's results in Fleet:This query has Discard data enabled."; + } + if (!loggingSnapshot) { + return "The following setting prevents saving this query's results in Fleet:The logging setting for this query is not Snapshot."; + } + return "Unknown"; + }; + return ( + <> + Results from this query are{" "} + + not reported in Fleet + + . + > + ); + } + if (errorsOnly) { + return ( + <> + This query had trouble collecting data on some hosts. Check out the{" "} + Errors tab to see why. + > + ); + } + return "This query has returned no data so far."; + }; + + return ( + + ); +}; + +export default NoResults; diff --git a/frontend/pages/queries/details/components/NoResults/index.ts b/frontend/pages/queries/details/components/NoResults/index.ts new file mode 100644 index 0000000000..04bef19e77 --- /dev/null +++ b/frontend/pages/queries/details/components/NoResults/index.ts @@ -0,0 +1 @@ +export { default } from "./NoResults"; diff --git a/frontend/pages/queries/QueryPage/QueryPage.tsx b/frontend/pages/queries/edit/EditQueryPage/EditQueryPage.tsx similarity index 54% rename from frontend/pages/queries/QueryPage/QueryPage.tsx rename to frontend/pages/queries/edit/EditQueryPage/EditQueryPage.tsx index ca93efe3d4..e9aa57dce9 100644 --- a/frontend/pages/queries/QueryPage/QueryPage.tsx +++ b/frontend/pages/queries/edit/EditQueryPage/EditQueryPage.tsx @@ -1,34 +1,36 @@ -import React, { useState, useEffect, useContext, useCallback } from "react"; -import { useQuery, useMutation } from "react-query"; +import React, { useState, useEffect, useContext } from "react"; +import { useQuery } from "react-query"; import { useErrorHandler } from "react-error-boundary"; import { InjectedRouter, Params } from "react-router/lib/Router"; import { AppContext } from "context/app"; import { QueryContext } from "context/query"; -import { QUERIES_PAGE_STEPS, DEFAULT_QUERY } from "utilities/constants"; +import { DEFAULT_QUERY } from "utilities/constants"; import queryAPI from "services/entities/queries"; -import hostAPI from "services/entities/hosts"; import statusAPI from "services/entities/status"; -import { IHost, IHostResponse } from "interfaces/host"; -import { ILabel } from "interfaces/label"; -import { ITeam } from "interfaces/team"; import { IGetQueryResponse, + ICreateQueryRequestBody, ISchedulableQuery, } from "interfaces/schedulable_query"; -import { ITarget } from "interfaces/target"; import QuerySidePanel from "components/side_panels/QuerySidePanel"; import MainContent from "components/MainContent"; import SidePanelContent from "components/SidePanelContent"; -import SelectTargets from "components/LiveQuery/SelectTargets"; 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 { +import { NotificationContext } from "context/notification"; + +import PATHS from "router/paths"; +import debounce from "utilities/debounce"; +import deepDifference from "utilities/deep_difference"; + +import BackLink from "components/BackLink"; +import QueryForm from "pages/queries/edit/components/QueryForm"; + +interface IEditQueryPageProps { router: InjectedRouter; params: Params; location: { @@ -38,13 +40,13 @@ interface IQueryPageProps { }; } -const baseClass = "query-page"; +const baseClass = "edit-query-page"; -const QueryPage = ({ +const EditQueryPage = ({ router, params: { id: paramsQueryId }, location, -}: IQueryPageProps): JSX.Element => { +}: IEditQueryPageProps): JSX.Element => { const queryId = paramsQueryId ? parseInt(paramsQueryId, 10) : null; const { currentTeamName: teamNameForQuery, @@ -67,6 +69,15 @@ const QueryPage = ({ const { selectedOsqueryTable, setSelectedOsqueryTable, + lastEditedQueryName, + lastEditedQueryDescription, + lastEditedQueryBody, + lastEditedQueryObserverCanRun, + lastEditedQueryFrequency, + lastEditedQueryPlatforms, + lastEditedQueryLoggingType, + lastEditedQueryMinOsqueryVersion, + selectedQueryTargets, setLastEditedQueryId, setLastEditedQueryName, setLastEditedQueryDescription, @@ -76,15 +87,14 @@ const QueryPage = ({ setLastEditedQueryLoggingType, setLastEditedQueryMinOsqueryVersion, setLastEditedQueryPlatforms, + // setSelectedQueryTargets, } = useContext(QueryContext); + const { currentUser } = useContext(AppContext); + const { renderFlash } = useContext(NotificationContext); + + // const [queryParamHostsAdded, setQueryParamHostsAdded] = useState(false); + // const [targetedHosts, setTargetedHosts] = useState([]); - const [queryParamHostsAdded, setQueryParamHostsAdded] = useState(false); - const [step, setStep] = useState(QUERIES_PAGE_STEPS[1]); - const [selectedTargets, setSelectedTargets] = useState([]); - const [targetedHosts, setTargetedHosts] = useState([]); - const [targetedLabels, setTargetedLabels] = useState([]); - const [targetedTeams, setTargetedTeams] = useState([]); - const [targetsTotalCount, setTargetsTotalCount] = useState(0); const [isLiveQueryRunnable, setIsLiveQueryRunnable] = useState(true); const [isSidebarOpen, setIsSidebarOpen] = useState(true); const [showOpenSchemaActionText, setShowOpenSchemaActionText] = useState( @@ -119,29 +129,6 @@ const QueryPage = ({ } ); - useQuery( - "hostFromURL", - () => - hostAPI.loadHostDetails(parseInt(location.query.host_ids as string, 10)), - { - enabled: !!location.query.host_ids && !queryParamHostsAdded, - select: (data: IHostResponse) => data.host, - onSuccess: (host) => { - setTargetedHosts((prevHosts) => - prevHosts.filter((h) => h.id !== host.id).concat(host) - ); - const targets = selectedTargets; - host.target_type = "hosts"; - targets.push(host); - setSelectedTargets([...targets]); - if (!queryParamHostsAdded) { - setQueryParamHostsAdded(true); - } - router.replace(location.pathname); - }, - } - ); - const detectIsFleetQueryRunnable = () => { statusAPI.live_query().catch(() => { setIsLiveQueryRunnable(false); @@ -163,16 +150,88 @@ const QueryPage = ({ } }, [queryId]); + const [isQuerySaving, setIsQuerySaving] = useState(false); + const [isQueryUpdating, setIsQueryUpdating] = useState(false); + const [backendValidators, setBackendValidators] = useState<{ + [key: string]: string; + }>({}); + // Updates title that shows up on browser tabs useEffect(() => { // e.g., Query details | Discover TLS certificates | Fleet for osquery - document.title = `Query details | ${storedQuery?.name} | Fleet for osquery`; + document.title = `Edit query | ${storedQuery?.name} | Fleet for osquery`; }, [location.pathname, storedQuery?.name]); useEffect(() => { setShowOpenSchemaActionText(!isSidebarOpen); }, [isSidebarOpen]); + const saveQuery = debounce(async (formData: ICreateQueryRequestBody) => { + setIsQuerySaving(true); + try { + const { query } = await queryAPI.create(formData); + router.push(PATHS.EDIT_QUERY(query.id)); + renderFlash("success", "Query created!"); + setBackendValidators({}); + } catch (createError: any) { + if (createError.data.errors[0].reason.includes("already exists")) { + const teamErrorText = + teamNameForQuery && apiTeamIdForQuery !== 0 + ? `the ${teamNameForQuery} team` + : "all teams"; + setBackendValidators({ + name: `A query with that name already exists for ${teamErrorText}.`, + }); + } else { + renderFlash( + "error", + "Something went wrong creating your query. Please try again." + ); + setBackendValidators({}); + } + } finally { + setIsQuerySaving(false); + } + }); + + const onUpdateQuery = async (formData: ICreateQueryRequestBody) => { + if (!queryId) { + return false; + } + + setIsQueryUpdating(true); + + const updatedQuery = deepDifference(formData, { + lastEditedQueryName, + lastEditedQueryDescription, + lastEditedQueryBody, + lastEditedQueryObserverCanRun, + lastEditedQueryFrequency, + lastEditedQueryPlatforms, + lastEditedQueryLoggingType, + lastEditedQueryMinOsqueryVersion, + }); + + try { + await queryAPI.update(queryId, updatedQuery); + renderFlash("success", "Query updated!"); + } catch (updateError: any) { + console.error(updateError); + if (updateError.data.errors[0].reason.includes("Duplicate")) { + renderFlash("error", "A query with this name already exists."); + } else { + renderFlash( + "error", + "Something went wrong updating your query. Please try again." + ); + } + } + + setIsQueryUpdating(false); + + return false; + }; + const onOsqueryTableSelect = (tableName: string) => { setSelectedOsqueryTable(tableName); }; @@ -207,64 +266,12 @@ const QueryPage = ({ ); }; - const goToQueryEditor = useCallback(() => setStep(QUERIES_PAGE_STEPS[1]), []); - - const renderScreen = () => { - const step1Props = { - router, - baseClass, - queryIdForEdit: queryId, - teamNameForQuery, - apiTeamIdForQuery, - showOpenSchemaActionText, - storedQuery, - isStoredQueryLoading, - storedQueryError, - onOsqueryTableSelect, - goToSelectTargets: () => setStep(QUERIES_PAGE_STEPS[2]), - onOpenSchemaSidebar, - renderLiveQueryWarning, - }; - - const step2Props = { - baseClass, - queryId, - selectedTargets, - targetedHosts, - targetedLabels, - targetedTeams, - targetsTotalCount, - goToQueryEditor: () => setStep(QUERIES_PAGE_STEPS[1]), - goToRunQuery: () => setStep(QUERIES_PAGE_STEPS[3]), - setSelectedTargets, - setTargetedHosts, - setTargetedLabels, - setTargetedTeams, - setTargetsTotalCount, - }; - - const step3Props = { - queryId, - selectedTargets, - storedQuery, - setSelectedTargets, - goToQueryEditor, - targetsTotalCount, - }; - - switch (step) { - case QUERIES_PAGE_STEPS[2]: - return ; - case QUERIES_PAGE_STEPS[3]: - return ; - default: - return ; - } + // Function instead of constant eliminates race condition + const backToQueriesPath = () => { + return queryId ? PATHS.QUERY(queryId) : PATHS.MANAGE_QUERIES; }; - const isFirstStep = step === QUERIES_PAGE_STEPS[1]; const showSidebar = - isFirstStep && isSidebarOpen && (isGlobalAdmin || isGlobalMaintainer || @@ -275,7 +282,31 @@ const QueryPage = ({ return ( <> - {renderScreen()} + + + + + + + + {showSidebar && ( @@ -290,4 +321,4 @@ const QueryPage = ({ ); }; -export default QueryPage; +export default EditQueryPage; diff --git a/frontend/pages/queries/edit/EditQueryPage/_styles.scss b/frontend/pages/queries/edit/EditQueryPage/_styles.scss new file mode 100644 index 0000000000..f4ffcbbfd2 --- /dev/null +++ b/frontend/pages/queries/edit/EditQueryPage/_styles.scss @@ -0,0 +1,54 @@ +.edit-query-page { + .body-wrap { + min-width: 0; + } + + &__warning { + padding: $pad-medium; + font-size: $x-small; + color: $core-fleet-black; + background-color: #fff0b9; + border: 1px solid #f2c94c; + border-radius: $border-radius; + margin: 0; + margin-top: $pad-large; + + p { + margin: 0; + line-height: 20px; + } + } + + .ace_content { + min-height: 500px !important; + } + + &__count-spinner { + margin-right: $pad-small; + } + &__page-loading { + .loading-spinner { + margin: $pad-large 0 0; + } + } + &__page-error { + h4 { + margin: 0; + margin-top: 28px; + margin-left: -7px; + font-size: $small; + + img { + transform: scale(0.5); + vertical-align: middle; + position: relative; + top: -2px; + } + } + p { + margin: 0; + margin-top: $pad-medium; + font-size: $x-small; + } + } +} diff --git a/frontend/pages/queries/edit/EditQueryPage/index.ts b/frontend/pages/queries/edit/EditQueryPage/index.ts new file mode 100644 index 0000000000..29c0d100ac --- /dev/null +++ b/frontend/pages/queries/edit/EditQueryPage/index.ts @@ -0,0 +1 @@ +export { default } from "./EditQueryPage"; diff --git a/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tests.tsx b/frontend/pages/queries/edit/components/QueryForm/QueryForm.tests.tsx similarity index 98% rename from frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tests.tsx rename to frontend/pages/queries/edit/components/QueryForm/QueryForm.tests.tsx index 87bc29c8fd..bb5286895f 100644 --- a/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tests.tsx +++ b/frontend/pages/queries/edit/components/QueryForm/QueryForm.tests.tsx @@ -68,7 +68,6 @@ describe("QueryForm - component", () => { isQueryUpdating={false} saveQuery={jest.fn()} onOsqueryTableSelect={jest.fn()} - goToSelectTargets={jest.fn()} onUpdate={jest.fn()} onOpenSchemaSidebar={jest.fn()} renderLiveQueryWarning={jest.fn()} diff --git a/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx b/frontend/pages/queries/edit/components/QueryForm/QueryForm.tsx similarity index 95% rename from frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx rename to frontend/pages/queries/edit/components/QueryForm/QueryForm.tsx index 452e06d4ee..a704ee2ef8 100644 --- a/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx +++ b/frontend/pages/queries/edit/components/QueryForm/QueryForm.tsx @@ -15,7 +15,11 @@ import PATHS from "router/paths"; import { AppContext } from "context/app"; import { QueryContext } from "context/query"; import { NotificationContext } from "context/notification"; -import { addGravatarUrlToResource, secondsToDhms } from "utilities/helpers"; +import { + addGravatarUrlToResource, + secondsToDhms, + TAGGED_TEMPLATES, +} from "utilities/helpers"; import { FREQUENCY_DROPDOWN_OPTIONS, SCHEDULE_PLATFORM_DROPDOWN_OPTIONS, @@ -48,6 +52,7 @@ import Spinner from "components/Spinner"; import Icon from "components/Icon/Icon"; import AutoSizeInputField from "components/forms/fields/AutoSizeInputField"; import SaveQueryModal from "../SaveQueryModal"; +import SaveChangesModal from "../SaveChangesModal"; const baseClass = "query-form"; @@ -63,11 +68,11 @@ interface IQueryFormProps { isQueryUpdating: boolean; saveQuery: (formData: ICreateQueryRequestBody) => void; onOsqueryTableSelect: (tableName: string) => void; - goToSelectTargets: () => void; onUpdate: (formData: ICreateQueryRequestBody) => void; onOpenSchemaSidebar: () => void; renderLiveQueryWarning: () => JSX.Element | null; backendValidators: { [key: string]: string }; + hostId?: number; } const validateQuerySQL = (query: string) => { @@ -110,11 +115,11 @@ const QueryForm = ({ isQueryUpdating, saveQuery, onOsqueryTableSelect, - goToSelectTargets, onUpdate, onOpenSchemaSidebar, renderLiveQueryWarning, backendValidators, + hostId, }: IQueryFormProps): JSX.Element => { // Note: The QueryContext values should always be used for any mutable query data such as query name // The storedQuery prop should only be used to access immutable metadata such as author id @@ -153,6 +158,7 @@ const QueryForm = ({ const savedQueryMode = !!queryIdForEdit; const [errors, setErrors] = useState<{ [key: string]: any }>({}); // string | null | undefined or boolean | undefined const [showSaveQueryModal, setShowSaveQueryModal] = useState(false); + const [showSaveChangesModal, setShowSaveChangesModal] = useState(false); // #7766 implementation const [showQueryEditor, setShowQueryEditor] = useState( isObserverPlus || isAnyTeamObserverPlus || false ); @@ -205,6 +211,11 @@ const QueryForm = ({ setShowSaveQueryModal(!showSaveQueryModal); }; + // #7766 implementation + const toggleSaveChangesModal = () => { + setShowSaveChangesModal(!showSaveChangesModal); + }; + const onLoad = (editor: IAceEditor) => { editor.setOptions({ enableLinking: true, @@ -400,6 +411,12 @@ const QueryForm = ({ logging: lastEditedQueryLoggingType, }); } + + // #7766 implementation + // savedQueryMode + // ? setShowSaveChangesModal(true) + // : setShowSaveQueryModal(true); + // TODO: onUpdate for saveChangesModal } }; @@ -575,7 +592,13 @@ const QueryForm = ({ { + queryIdForEdit && + router.push( + PATHS.LIVE_QUERY(queryIdForEdit) + + TAGGED_TEMPLATES.queryByHostRoute(hostId) + ); + }} > Live query @@ -749,7 +772,13 @@ const QueryForm = ({ { + queryIdForEdit && + router.push( + PATHS.LIVE_QUERY(queryIdForEdit) + + TAGGED_TEMPLATES.queryByHostRoute(hostId) + ); + }} > Live query @@ -765,6 +794,13 @@ const QueryForm = ({ isLoading={isQuerySaving} /> )} + {showSaveChangesModal && ( + + )} > ); }; diff --git a/frontend/pages/queries/QueryPage/components/QueryForm/_styles.scss b/frontend/pages/queries/edit/components/QueryForm/_styles.scss similarity index 98% rename from frontend/pages/queries/QueryPage/components/QueryForm/_styles.scss rename to frontend/pages/queries/edit/components/QueryForm/_styles.scss index 5a9ab0d49f..bc63146bac 100644 --- a/frontend/pages/queries/QueryPage/components/QueryForm/_styles.scss +++ b/frontend/pages/queries/edit/components/QueryForm/_styles.scss @@ -3,11 +3,6 @@ position: relative; font-size: $x-small; - .query-page__warning { - margin: 0; - margin-top: $pad-large; - } - .form-field--input { margin: 0; } diff --git a/frontend/pages/queries/QueryPage/components/QueryForm/index.ts b/frontend/pages/queries/edit/components/QueryForm/index.ts similarity index 100% rename from frontend/pages/queries/QueryPage/components/QueryForm/index.ts rename to frontend/pages/queries/edit/components/QueryForm/index.ts diff --git a/frontend/pages/queries/QueryPage/components/QueryResults/QueryResults.tsx b/frontend/pages/queries/edit/components/QueryResults/QueryResults.tsx similarity index 100% rename from frontend/pages/queries/QueryPage/components/QueryResults/QueryResults.tsx rename to frontend/pages/queries/edit/components/QueryResults/QueryResults.tsx diff --git a/frontend/pages/queries/QueryPage/components/QueryResults/QueryResultsTableConfig.tsx b/frontend/pages/queries/edit/components/QueryResults/QueryResultsTableConfig.tsx similarity index 100% rename from frontend/pages/queries/QueryPage/components/QueryResults/QueryResultsTableConfig.tsx rename to frontend/pages/queries/edit/components/QueryResults/QueryResultsTableConfig.tsx diff --git a/frontend/pages/queries/QueryPage/components/QueryResults/_styles.scss b/frontend/pages/queries/edit/components/QueryResults/_styles.scss similarity index 100% rename from frontend/pages/queries/QueryPage/components/QueryResults/_styles.scss rename to frontend/pages/queries/edit/components/QueryResults/_styles.scss diff --git a/frontend/pages/queries/QueryPage/components/QueryResults/index.ts b/frontend/pages/queries/edit/components/QueryResults/index.ts similarity index 100% rename from frontend/pages/queries/QueryPage/components/QueryResults/index.ts rename to frontend/pages/queries/edit/components/QueryResults/index.ts diff --git a/frontend/pages/queries/edit/components/SaveChangesModal/SaveChangesModal.tsx b/frontend/pages/queries/edit/components/SaveChangesModal/SaveChangesModal.tsx new file mode 100644 index 0000000000..362af3918d --- /dev/null +++ b/frontend/pages/queries/edit/components/SaveChangesModal/SaveChangesModal.tsx @@ -0,0 +1,53 @@ +import React from "react"; + +import Button from "components/buttons/Button"; +import Modal from "components/Modal"; +import { ICreateQueryRequestBody } from "interfaces/schedulable_query"; + +const baseClass = "save-changes-modal"; + +export interface ISaveChangesModalProps { + isUpdating: boolean; + onSaveChanges: (formData: ICreateQueryRequestBody) => void; + toggleSaveChangesModal: () => void; + sqlUpdated?: boolean; +} + +const SaveChangesModal = ({ + isUpdating, + onSaveChanges, + toggleSaveChangesModal, + sqlUpdated = false, +}: ISaveChangesModalProps): JSX.Element => { + const warningText = () => { + if (sqlUpdated) { + return "Changing this query's SQL will delete its previous results, since the existing report does not reflect the updated query."; + } + return "The changes you are making to this query will delete its previous results."; + }; + + return ( + + + {warningText()} + You cannot undo this action. + + + Save + + + Cancel + + + + + ); +}; + +export default SaveChangesModal; diff --git a/frontend/pages/queries/edit/components/SaveChangesModal/index.ts b/frontend/pages/queries/edit/components/SaveChangesModal/index.ts new file mode 100644 index 0000000000..15fa4a05cf --- /dev/null +++ b/frontend/pages/queries/edit/components/SaveChangesModal/index.ts @@ -0,0 +1 @@ +export { default } from "./SaveChangesModal"; diff --git a/frontend/pages/queries/QueryPage/components/SaveQueryModal/SaveQueryModal.tsx b/frontend/pages/queries/edit/components/SaveQueryModal/SaveQueryModal.tsx similarity index 51% rename from frontend/pages/queries/QueryPage/components/SaveQueryModal/SaveQueryModal.tsx rename to frontend/pages/queries/edit/components/SaveQueryModal/SaveQueryModal.tsx index f74a45f66c..a8cd8dc099 100644 --- a/frontend/pages/queries/QueryPage/components/SaveQueryModal/SaveQueryModal.tsx +++ b/frontend/pages/queries/edit/components/SaveQueryModal/SaveQueryModal.tsx @@ -141,116 +141,114 @@ const SaveQueryModal = ({ return ( - <> - + setName(value)} + value={name} + error={errors.name} + inputClassName={`${baseClass}__name`} + label="Name" + placeholder="What is your query called?" + autofocus + ignore1password + /> + setDescription(value)} + value={description} + inputClassName={`${baseClass}__description`} + label="Description" + type="textarea" + placeholder="What information does your query reveal? (optional)" + /> + { + setSelectedFrequency(value); + }} + placeholder={"Every hour"} + value={selectedFrequency} + label="Frequency" + wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`} + /> + + If automations are on, this is how often your query collects data. + + - setName(value)} - value={name} - error={errors.name} - inputClassName={`${baseClass}__name`} - label="Name" - placeholder="What is your query called?" - autofocus - ignore1password - /> - setDescription(value)} - value={description} - inputClassName={`${baseClass}__description`} - label="Description" - type="textarea" - placeholder="What information does your query reveal? (optional)" - /> - { - setSelectedFrequency(value); - }} - placeholder={"Every hour"} - value={selectedFrequency} - label="Frequency" - wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`} - /> - - If automations are on, this is how often your query collects data. - - + + Users with the Observer role will be able to run this query as a live + query. + + + {showAdvancedOptions && ( + <> + + + If automations are turned on, your query collects data on + compatible platforms. + + If you want more control, override platforms. + + + + > + )} + + - Observers can run - - - Users with the Observer role will be able to run this query as a - live query. - - - {showAdvancedOptions && ( - <> - - - If automations are turned on, your query collects data on - compatible platforms. - - If you want more control, override platforms. - - - - > - )} - - - Save - - - Cancel - - - - > + Save + + + Cancel + + + ); }; diff --git a/frontend/pages/queries/QueryPage/components/SaveQueryModal/_styles.scss b/frontend/pages/queries/edit/components/SaveQueryModal/_styles.scss similarity index 100% rename from frontend/pages/queries/QueryPage/components/SaveQueryModal/_styles.scss rename to frontend/pages/queries/edit/components/SaveQueryModal/_styles.scss diff --git a/frontend/pages/queries/QueryPage/components/SaveQueryModal/index.ts b/frontend/pages/queries/edit/components/SaveQueryModal/index.ts similarity index 100% rename from frontend/pages/queries/QueryPage/components/SaveQueryModal/index.ts rename to frontend/pages/queries/edit/components/SaveQueryModal/index.ts diff --git a/frontend/pages/queries/live/LiveQueryPage/LiveQueryPage.tsx b/frontend/pages/queries/live/LiveQueryPage/LiveQueryPage.tsx new file mode 100644 index 0000000000..c49d4c333b --- /dev/null +++ b/frontend/pages/queries/live/LiveQueryPage/LiveQueryPage.tsx @@ -0,0 +1,213 @@ +import React, { useState, useEffect, useContext, useCallback } from "react"; +import { useQuery } from "react-query"; +import { useErrorHandler } from "react-error-boundary"; +import { InjectedRouter, Params } from "react-router/lib/Router"; +import PATHS from "router/paths"; + +import { AppContext } from "context/app"; +import { QueryContext } from "context/query"; +import { LIVE_QUERY_STEPS, DEFAULT_QUERY } from "utilities/constants"; +import queryAPI from "services/entities/queries"; +import hostAPI from "services/entities/hosts"; +import statusAPI from "services/entities/status"; +import { IHost, IHostResponse } from "interfaces/host"; +import { ILabel } from "interfaces/label"; +import { ITeam } from "interfaces/team"; +import { + IGetQueryResponse, + ISchedulableQuery, +} from "interfaces/schedulable_query"; + +import MainContent from "components/MainContent"; +import SelectTargets from "components/LiveQuery/SelectTargets"; + +import RunQuery from "pages/queries/live/screens/RunQuery"; +import useTeamIdParam from "hooks/useTeamIdParam"; + +interface IRunQueryPageProps { + router: InjectedRouter; + params: Params; + location: { + pathname: string; + query: { host_ids: string; team_id?: string }; + search: string; + }; +} + +const baseClass = "run-query-page"; + +const RunQueryPage = ({ + router, + params: { id: paramsQueryId }, + location, +}: IRunQueryPageProps): JSX.Element => { + const queryId = paramsQueryId ? parseInt(paramsQueryId, 10) : null; + const { + currentTeamName: teamNameForQuery, + teamIdForApi: apiTeamIdForQuery, + } = useTeamIdParam({ + location, + router, + includeAllTeams: true, + includeNoTeam: false, + }); + + const handlePageError = useErrorHandler(); + const { + isGlobalAdmin, + isGlobalMaintainer, + isAnyTeamMaintainerOrTeamAdmin, + isObserverPlus, + isAnyTeamObserverPlus, + } = useContext(AppContext); + const { + selectedQueryTargets, + setSelectedQueryTargets, + setLastEditedQueryId, + setLastEditedQueryName, + setLastEditedQueryDescription, + setLastEditedQueryBody, + setLastEditedQueryObserverCanRun, + setLastEditedQueryFrequency, + setLastEditedQueryLoggingType, + setLastEditedQueryMinOsqueryVersion, + setLastEditedQueryPlatforms, + } = useContext(QueryContext); + + const [queryParamHostsAdded, setQueryParamHostsAdded] = useState(false); + const [step, setStep] = useState(LIVE_QUERY_STEPS[1]); + const [targetedHosts, setTargetedHosts] = useState([]); + const [targetedLabels, setTargetedLabels] = useState([]); + const [targetedTeams, setTargetedTeams] = useState([]); + const [targetsTotalCount, setTargetsTotalCount] = useState(0); + const [isLiveQueryRunnable, setIsLiveQueryRunnable] = useState(true); + + const TAGGED_TEMPLATES = { + queryByHostRoute: (hostId: number | undefined | null) => { + return `${hostId ? `?host_ids=${hostId}` : ""}`; + }, + }; + + // disabled on page load so we can control the number of renders + // else it will re-populate the context on occasion + const { data: storedQuery } = useQuery< + IGetQueryResponse, + Error, + ISchedulableQuery + >(["query", queryId], () => queryAPI.load(queryId as number), { + enabled: !!queryId, + refetchOnWindowFocus: false, + select: (data) => data.query, + onSuccess: (returnedQuery) => { + setLastEditedQueryId(returnedQuery.id); + setLastEditedQueryName(returnedQuery.name); + setLastEditedQueryDescription(returnedQuery.description); + setLastEditedQueryBody(returnedQuery.query); + setLastEditedQueryObserverCanRun(returnedQuery.observer_can_run); + setLastEditedQueryFrequency(returnedQuery.interval); + setLastEditedQueryPlatforms(returnedQuery.platform); + setLastEditedQueryLoggingType(returnedQuery.logging); + setLastEditedQueryMinOsqueryVersion(returnedQuery.min_osquery_version); + }, + onError: (error) => handlePageError(error), + }); + + useQuery( + "hostFromURL", + () => + hostAPI.loadHostDetails(parseInt(location.query.host_ids as string, 10)), + { + enabled: !!location.query.host_ids && !queryParamHostsAdded, + select: (data: IHostResponse) => data.host, + onSuccess: (host) => { + setTargetedHosts((prevHosts) => + prevHosts.filter((h) => h.id !== host.id).concat(host) + ); + const targets = selectedQueryTargets; + host.target_type = "hosts"; + targets.push(host); + setSelectedQueryTargets([...targets]); + if (!queryParamHostsAdded) { + setQueryParamHostsAdded(true); + } + router.replace(location.pathname); + }, + } + ); + + const detectIsFleetQueryRunnable = () => { + statusAPI.live_query().catch(() => { + setIsLiveQueryRunnable(false); + }); + }; + + useEffect(() => { + detectIsFleetQueryRunnable(); + if (!queryId) { + setLastEditedQueryId(DEFAULT_QUERY.id); + setLastEditedQueryName(DEFAULT_QUERY.name); + setLastEditedQueryDescription(DEFAULT_QUERY.description); + setLastEditedQueryBody(DEFAULT_QUERY.query); + setLastEditedQueryObserverCanRun(DEFAULT_QUERY.observer_can_run); + setLastEditedQueryFrequency(DEFAULT_QUERY.interval); + setLastEditedQueryLoggingType(DEFAULT_QUERY.logging); + setLastEditedQueryMinOsqueryVersion(DEFAULT_QUERY.min_osquery_version); + setLastEditedQueryPlatforms(DEFAULT_QUERY.platform); + } + }, [queryId]); + + // Updates title that shows up on browser tabs + useEffect(() => { + // e.g., Run live query | Discover TLS certificates | Fleet for osquery + document.title = `Run live query | ${storedQuery?.name} | Fleet for osquery`; + }, [location.pathname, storedQuery?.name]); + + const goToQueryEditor = useCallback( + () => queryId && router.push(PATHS.EDIT_QUERY(queryId)), + [] + ); + // const params = { id: paramsQueryId }; + + const renderScreen = () => { + const step1Props = { + baseClass, + queryId, + selectedTargets: selectedQueryTargets, + targetedHosts, + targetedLabels, + targetedTeams, + targetsTotalCount, + goToQueryEditor, + goToRunQuery: () => setStep(LIVE_QUERY_STEPS[2]), + setSelectedTargets: setSelectedQueryTargets, + setTargetedHosts, + setTargetedLabels, + setTargetedTeams, + setTargetsTotalCount, + }; + + const step2Props = { + queryId, + selectedTargets: selectedQueryTargets, + storedQuery, + setSelectedTargets: setSelectedQueryTargets, + goToQueryEditor, + targetsTotalCount, + }; + + switch (step) { + case LIVE_QUERY_STEPS[2]: + return ; + default: + return ; + } + }; + + return ( + + {renderScreen()} + + ); +}; + +export default RunQueryPage; diff --git a/frontend/pages/queries/QueryPage/_styles.scss b/frontend/pages/queries/live/LiveQueryPage/_styles.scss similarity index 69% rename from frontend/pages/queries/QueryPage/_styles.scss rename to frontend/pages/queries/live/LiveQueryPage/_styles.scss index 8e1198b7f8..1ec2385d09 100644 --- a/frontend/pages/queries/QueryPage/_styles.scss +++ b/frontend/pages/queries/live/LiveQueryPage/_styles.scss @@ -1,4 +1,4 @@ -.query-page { +.run-query-page { .body-wrap { min-width: 0; } @@ -9,61 +9,6 @@ min-height: 400px; } - &__warning { - padding: $pad-medium; - font-size: $x-small; - color: $core-fleet-black; - background-color: #fff0b9; - border: 1px solid #f2c94c; - border-radius: $border-radius; - - p { - margin: 0; - line-height: 20px; - } - } - - &__observer-query-view { - width: 90%; - max-width: 1060px; - margin: 0 auto; - color: $core-fleet-black; - - h1 { - font-size: $medium; - } - p { - font-size: $x-small; - } - } - - &__observer-query-details { - padding: 0 2rem; - - h1 { - margin: $pad-large 0; - font-size: $large; - } - - p { - margin-bottom: $pad-small; - } - - .sql-button { - color: $core-vibrant-blue; - font-weight: $bold; - font-size: $x-small; - } - } - - &__query-preview { - margin-top: 15px; - - .fleet-ace__label { - display: none; - } - } - .ace_content { min-height: 500px !important; } @@ -172,10 +117,4 @@ font-size: $x-small; } } - - .targets-input { - .input-icon-field__icon { - top: 34px; // Override styling to include label header - } - } } diff --git a/frontend/pages/queries/live/LiveQueryPage/index.ts b/frontend/pages/queries/live/LiveQueryPage/index.ts new file mode 100644 index 0000000000..354c0445d1 --- /dev/null +++ b/frontend/pages/queries/live/LiveQueryPage/index.ts @@ -0,0 +1 @@ +export { default } from "./LiveQueryPage"; diff --git a/frontend/pages/queries/QueryPage/screens/RunQuery.tsx b/frontend/pages/queries/live/screens/RunQuery.tsx similarity index 98% rename from frontend/pages/queries/QueryPage/screens/RunQuery.tsx rename to frontend/pages/queries/live/screens/RunQuery.tsx index dbceec8069..7715899b34 100644 --- a/frontend/pages/queries/QueryPage/screens/RunQuery.tsx +++ b/frontend/pages/queries/live/screens/RunQuery.tsx @@ -15,7 +15,7 @@ import { ICampaign, ICampaignState } from "interfaces/campaign"; import { IQuery } from "interfaces/query"; import { ITarget } from "interfaces/target"; -import QueryResults from "../components/QueryResults"; +import QueryResults from "../../edit/components/QueryResults"; interface IRunQueryProps { storedQuery: IQuery | undefined; diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx index f314b5ecc8..07f1256436 100644 --- a/frontend/router/index.tsx +++ b/frontend/router/index.tsx @@ -36,7 +36,9 @@ import ManagePoliciesPage from "pages/policies/ManagePoliciesPage"; import NoAccessPage from "pages/NoAccessPage"; import PackComposerPage from "pages/packs/PackComposerPage"; import PolicyPage from "pages/policies/PolicyPage"; -import QueryPage from "pages/queries/QueryPage"; +import QueryDetailsPage from "pages/queries/details/QueryDetailsPage"; +import LiveQueryPage from "pages/queries/live/LiveQueryPage"; +import EditQueryPage from "pages/queries/edit/EditQueryPage"; import RegistrationPage from "pages/RegistrationPage"; import ResetPasswordPage from "pages/ResetPasswordPage"; import MDMAppleSSOPage from "pages/MDMAppleSSOPage"; @@ -220,9 +222,13 @@ const routes = ( - + + + + + + - diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 834403705e..3190f09c45 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -53,6 +53,16 @@ export default { return `${URL_PREFIX}/labels/${labelId}`; }, EDIT_QUERY: (queryId: number, teamId?: number): string => { + return `${URL_PREFIX}/queries/${queryId}/edit${ + teamId ? `?team_id=${teamId}` : "" + }`; + }, + LIVE_QUERY: (queryId: number, teamId?: number): string => { + return `${URL_PREFIX}/queries/${queryId}/live${ + teamId ? `?team_id=${teamId}` : "" + }`; + }, + QUERY: (queryId: number, teamId?: number): string => { return `${URL_PREFIX}/queries/${queryId}${ teamId ? `?team_id=${teamId}` : "" }`; diff --git a/frontend/services/entities/queries.ts b/frontend/services/entities/queries.ts index 89765f6481..bfad2ff9ed 100644 --- a/frontend/services/entities/queries.ts +++ b/frontend/services/entities/queries.ts @@ -54,10 +54,10 @@ export default { queryId: number | null; selected: ISelectedTargets; }) => { - const { RUN_QUERY } = endpoints; + const { LIVE_QUERY } = endpoints; try { - const { campaign } = await sendRequest("POST", RUN_QUERY, { + const { campaign } = await sendRequest("POST", LIVE_QUERY, { query, query_id: queryId, selected, diff --git a/frontend/utilities/constants.ts b/frontend/utilities/constants.ts index 14530e240b..079e929753 100644 --- a/frontend/utilities/constants.ts +++ b/frontend/utilities/constants.ts @@ -96,12 +96,17 @@ export const MIN_OSQUERY_VERSION_OPTIONS = [ { label: "1.8.1 +", value: "1.8.1" }, ]; -export const QUERIES_PAGE_STEPS = { +export const LIVE_POLICY_STEPS = { 1: "EDITOR", 2: "TARGETS", 3: "RUN", }; +export const LIVE_QUERY_STEPS = { + 1: "TARGETS", + 2: "RUN", +}; + export const DEFAULT_QUERY: ISchedulableQuery = { description: "", name: "", diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index ce6d89b952..e1a3880b99 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -82,7 +82,7 @@ export default { PERFORM_REQUIRED_PASSWORD_RESET: `/${API_VERSION}/fleet/perform_required_password_reset`, QUERIES: `/${API_VERSION}/fleet/queries`, RESET_PASSWORD: `/${API_VERSION}/fleet/reset_password`, - RUN_QUERY: `/${API_VERSION}/fleet/queries/run`, + LIVE_QUERY: `/${API_VERSION}/fleet/queries/run`, SCHEDULE_QUERY: `/${API_VERSION}/fleet/packs/schedule`, SCHEDULED_QUERIES: (packId: number): string => { return `/${API_VERSION}/fleet/packs/${packId}/scheduled`; diff --git a/frontend/utilities/helpers.ts b/frontend/utilities/helpers.ts index 641b5cf734..c519b9a263 100644 --- a/frontend/utilities/helpers.ts +++ b/frontend/utilities/helpers.ts @@ -910,6 +910,12 @@ export const getSoftwareBundleTooltipMarkup = (bundle: string) => { `; }; +export const TAGGED_TEMPLATES = { + queryByHostRoute: (hostId: number | undefined | null) => { + return `${hostId ? `?host_ids=${hostId}` : ""}`; + }, +}; + export default { addGravatarUrlToResource, formatConfigDataForServer, @@ -945,4 +951,5 @@ export default { syntaxHighlight, normalizeEmptyValues, wrapFleetHelper, + TAGGED_TEMPLATES, };
- There are currently no results to your query. Please wait while we talk - to more hosts. -
+ {lastEditedQueryDescription} +
{warningText()}
You cannot undo this action.
+ If automations are on, this is how often your query collects data. +
- If automations are on, this is how often your query collects data. -
+ Users with the Observer role will be able to run this query as a live + query. +
+ If automations are turned on, your query collects data on + compatible platforms. + + If you want more control, override platforms. +
- Users with the Observer role will be able to run this query as a - live query. -
- If automations are turned on, your query collects data on - compatible platforms. - - If you want more control, override platforms. -