diff --git a/changes/issue-2713-policy-live-queries b/changes/issue-2713-policy-live-queries new file mode 100644 index 0000000000..91900e55ce --- /dev/null +++ b/changes/issue-2713-policy-live-queries @@ -0,0 +1 @@ +* Add ability to run live queries on new and existing policies diff --git a/frontend/components/InfoBanner/_styles.scss b/frontend/components/InfoBanner/_styles.scss index 72c324c8c3..9c7b41a371 100644 --- a/frontend/components/InfoBanner/_styles.scss +++ b/frontend/components/InfoBanner/_styles.scss @@ -3,4 +3,5 @@ border-radius: $border-radius; border: 1px solid #d9d9fe; background-color: $ui-vibrant-blue-10; + font-size: 14px; } diff --git a/frontend/interfaces/host.ts b/frontend/interfaces/host.ts index 0e8fc540c0..120e1b007b 100644 --- a/frontend/interfaces/host.ts +++ b/frontend/interfaces/host.ts @@ -4,6 +4,7 @@ import hostUserInterface, { IHostUser } from "./host_users"; import labelInterface, { ILabel } from "./label"; import packInterface, { IPack } from "./pack"; import softwareInterface, { ISoftware } from "./software"; +import hostQueryResult from "./campaign"; import queryStatsInterface, { IQueryStats } from "./query_stats"; export default PropTypes.shape({ @@ -58,6 +59,7 @@ export default PropTypes.shape({ display_text: PropTypes.string, users: PropTypes.arrayOf(hostUserInterface), policies: PropTypes.arrayOf(hostPolicyInterface), + query_results: PropTypes.arrayOf(hostQueryResult), }); export interface IDeviceUser { @@ -83,6 +85,18 @@ export interface IPackStats { type: string; } +export interface IHostPolicyQuery { + id: number; + hostname: string; + status?: string; +} + +export interface IHostPolicyQueryError { + host_hostname: string; + osquery_version: string; + error: string; +} + export interface IHost { created_at: string; updated_at: string; @@ -136,4 +150,5 @@ export interface IHost { munki?: IMunkiData; mdm?: IMDMData; policies: IHostPolicy[]; + query_results?: []; } diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx index e902d69fb6..260a62fa09 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx @@ -97,16 +97,6 @@ const ManagePolicyPage = (managePoliciesPageProps: { refetchOnWindowFocus: false, }); - const { data: fleetQueries } = useQuery( - ["fleetQueries"], - () => fleetQueriesAPI.loadAll(), - { - select: (data) => data.queries, - refetchOnMount: false, - refetchOnWindowFocus: false, - } - ); - // ===== local state const [globalPolicies, setGlobalPolicies] = useState< IPolicyStats[] | never[] diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesListWrapper/PoliciesListWrapper.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesListWrapper/PoliciesListWrapper.tsx index 188c0ba9db..07cea031cf 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesListWrapper/PoliciesListWrapper.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesListWrapper/PoliciesListWrapper.tsx @@ -2,7 +2,6 @@ import React from "react"; import { noop } from "lodash"; import paths from "router/paths"; -import Button from "components/buttons/Button"; import { IPolicyStats } from "interfaces/policy"; import { ITeam } from "interfaces/team"; import TableContainer from "components/TableContainer"; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesListWrapper/PoliciesTableConfig.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesListWrapper/PoliciesTableConfig.tsx index 72eed6cdbd..ad1de9d0e8 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesListWrapper/PoliciesTableConfig.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesListWrapper/PoliciesTableConfig.tsx @@ -7,7 +7,6 @@ import { memoize } from "lodash"; // @ts-ignore import Checkbox from "components/forms/fields/Checkbox"; import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell"; -import TextCell from "components/TableContainer/DataTable/TextCell"; import { IPolicyStats } from "interfaces/policy"; import PATHS from "router/paths"; import sortUtils from "utilities/sort"; diff --git a/frontend/pages/policies/PolicyPage/PolicyPage.tsx b/frontend/pages/policies/PolicyPage/PolicyPage.tsx index 315f14f134..40e4e6ea0e 100644 --- a/frontend/pages/policies/PolicyPage/PolicyPage.tsx +++ b/frontend/pages/policies/PolicyPage/PolicyPage.tsx @@ -50,9 +50,6 @@ const PolicyPage = ({ const { selectedOsqueryTable, setSelectedOsqueryTable, - lastEditedQueryName, - lastEditedQueryDescription, - lastEditedQueryBody, setLastEditedQueryName, setLastEditedQueryDescription, setLastEditedQueryBody, @@ -195,7 +192,6 @@ const PolicyPage = ({ const step2Opts = { baseClass, selectedTargets: [...selectedTargets], - policyIdForEdit, goToQueryEditor: () => setStep(QUERIES_PAGE_STEPS[1]), goToRunQuery: () => setStep(QUERIES_PAGE_STEPS[3]), setSelectedTargets, diff --git a/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsListWrapper/PolicyQueriesErrorsListWrapper.tsx b/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsListWrapper/PolicyQueriesErrorsListWrapper.tsx new file mode 100644 index 0000000000..09c55efb11 --- /dev/null +++ b/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsListWrapper/PolicyQueriesErrorsListWrapper.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { noop } from "lodash"; + +import { IHostPolicyQueryError } from "interfaces/host"; +import TableContainer from "components/TableContainer"; +import { + generateTableHeaders, + generateDataSet, +} from "./PolicyQueriesErrorsTableConfig"; + +const baseClass = "policies-queries-list-wrapper"; +const noPolicyQueries = "no-policy-queries"; + +interface IPoliciesListWrapperProps { + errorsList: IHostPolicyQueryError[]; + isLoading: boolean; + resultsTitle?: string; + canAddOrRemovePolicy?: boolean; +} + +const PoliciesListWrapper = ({ + errorsList, + isLoading, + resultsTitle, + canAddOrRemovePolicy, +}: IPoliciesListWrapperProps): JSX.Element => { + const NoPolicyQueries = () => { + return ( +
+

No hosts are online.

+
+ ); + }; + + return ( +
+ +
+ ); +}; + +export default PoliciesListWrapper; diff --git a/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsListWrapper/PolicyQueriesErrorsTableConfig.tsx b/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsListWrapper/PolicyQueriesErrorsTableConfig.tsx new file mode 100644 index 0000000000..23ebe22d72 --- /dev/null +++ b/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsListWrapper/PolicyQueriesErrorsTableConfig.tsx @@ -0,0 +1,86 @@ +/* eslint-disable react/prop-types */ +// disable this rule as it was throwing an error in Header and Cell component +// definitions for the selection row for some reason when we dont really need it. +import React from "react"; +import { memoize } from "lodash"; + +// @ts-ignore +import TextCell from "components/TableContainer/DataTable/TextCell/TextCell"; +import { IHostPolicyQueryError } from "interfaces/host"; +import sortUtils from "utilities/sort"; + +// TODO functions for paths math e.g., path={PATHS.MANAGE_HOSTS + getParams(cellProps.row.original)} + +interface IHeaderProps { + column: { + host: string; + isSortedDesc: boolean; + }; +} + +interface ICellProps { + cell: { + value: any; + }; + row: { + original: IHostPolicyQueryError; + }; +} + +interface IDataColumn { + Header: ((props: IHeaderProps) => JSX.Element) | string; + Cell: (props: ICellProps) => JSX.Element; + title?: string; + accessor?: string; + disableHidden?: boolean; + disableSortBy?: boolean; + sortType?: string; +} + +// NOTE: cellProps come from react-table +// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties +const generateTableHeaders = (): IDataColumn[] => { + const tableHeaders: IDataColumn[] = [ + { + title: "Host", + Header: "Host", + disableSortBy: true, + accessor: "host_hostname", + Cell: (cellProps: ICellProps): JSX.Element => ( + + ), + }, + { + title: "OSQuery Version", + Header: "OSQuery Version", + disableSortBy: true, + accessor: "osquery_version", + Cell: (cellProps: ICellProps): JSX.Element => ( + + ), + }, + { + title: "Error", + Header: "Error", + disableSortBy: true, + accessor: "error", + Cell: (cellProps: ICellProps): JSX.Element => ( + + ), + }, + ]; + return tableHeaders; +}; + +const generateDataSet = memoize( + ( + policyHostsErrorsList: IHostPolicyQueryError[] = [] + ): IHostPolicyQueryError[] => { + policyHostsErrorsList = policyHostsErrorsList.sort((a, b) => + sortUtils.caseInsensitiveAsc(a.host_hostname, b.host_hostname) + ); + return policyHostsErrorsList; + } +); + +export { generateTableHeaders, generateDataSet }; diff --git a/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsListWrapper/_styles.scss b/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsListWrapper/_styles.scss new file mode 100644 index 0000000000..a1f6e284b4 --- /dev/null +++ b/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsListWrapper/_styles.scss @@ -0,0 +1,140 @@ +.policies-queries-list-wrapper { + border-collapse: collapse; + + a { + color: $core-vibrant-blue; + font-size: $x-small; + text-decoration: none; + } + + &__wrapper { + border: 1px solid $ui-fleet-blue-15; + border-radius: 4px; + overflow: hidden; + margin-top: $pad-medium; + } + + .table-container { + margin-top: $pad-small; + } + + .table-container__header { + display: none; + } + + thead { + background-color: $ui-off-white; + border-bottom: 1px solid $ui-fleet-blue-15; + + th { + font-size: $x-small; + font-weight: $bold; + text-align: left; + padding: $pad-medium $pad-large; + } + + .host_hostname__header { + width: 50%; + } + } + + tbody td img { + width: 16px; + height: 16px; + vertical-align: sub; + padding-right: 4px; + } + + &__th-pack-name { + padding-left: 0; + text-align: left; + } + + &__select-all { + margin-bottom: 0; + } + + &__empty-table { + text-align: center; + font-size: $x-small; + color: $core-fleet-black; + } + + &__policy-count { + color: $core-fleet-black; + font-size: $x-small; + font-weight: $bold; + margin: 0 12px 0 0; + display: inline-block; + } +} + +.no-policies { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding-top: $pad-xxxlarge; + + a { + color: $core-vibrant-blue; + font-size: $x-small; + text-decoration: none; + } + + h1 { + font-size: $large; + font-weight: $regular; + line-height: normal; + letter-spacing: normal; + color: $core-fleet-black; + } + + h2 { + font-size: $small; + font-weight: $bold; + margin: 0 0 $pad-large; + line-height: 20px; + color: $core-fleet-black; + } + + &__inner { + display: flex; + flex-direction: column; + align-items: center; + + h1 { + font-size: $small; + font-weight: $bold; + margin-bottom: $pad-medium; + } + + img { + width: 322px; + } + + p { + color: $core-fleet-black; + font-weight: $regular; + font-size: $x-small; + margin: 0; + margin-bottom: $pad-large; + } + } + + &__inner-text { + width: 500px; + padding: $pad-xxlarge 0; + } + + &__bullet-text { + width: 455px; + text-align: left; + } +} + +.no-team-policy { + border: 1px solid #e2e4ea; + box-sizing: border-box; + border-radius: 8px; +} diff --git a/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsListWrapper/index.ts b/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsListWrapper/index.ts new file mode 100644 index 0000000000..1ea9e64757 --- /dev/null +++ b/frontend/pages/policies/PolicyPage/components/PolicyQueriesErrorsListWrapper/index.ts @@ -0,0 +1 @@ +export { default } from "./PolicyQueriesErrorsListWrapper"; diff --git a/frontend/pages/policies/PolicyPage/components/PolicyQueriesListWrapper/PolicyQueriesListWrapper.tsx b/frontend/pages/policies/PolicyPage/components/PolicyQueriesListWrapper/PolicyQueriesListWrapper.tsx new file mode 100644 index 0000000000..292592c560 --- /dev/null +++ b/frontend/pages/policies/PolicyPage/components/PolicyQueriesListWrapper/PolicyQueriesListWrapper.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { noop } from "lodash"; + +import { IHostPolicyQuery } from "interfaces/host"; +import TableContainer from "components/TableContainer"; +import { + generateTableHeaders, + generateDataSet, +} from "./PolicyQueriesTableConfig"; + +const baseClass = "policies-queries-list-wrapper"; +const noPolicyQueries = "no-policy-queries"; + +interface IPoliciesListWrapperProps { + policyHostsList: IHostPolicyQuery[]; + isLoading: boolean; + resultsTitle?: string; + canAddOrRemovePolicy?: boolean; +} + +const PoliciesListWrapper = ({ + policyHostsList, + isLoading, + resultsTitle, + canAddOrRemovePolicy, +}: IPoliciesListWrapperProps): JSX.Element => { + const NoPolicyQueries = () => { + return ( +
+

No hosts are online.

+
+ ); + }; + + return ( +
+ +
+ ); +}; + +export default PoliciesListWrapper; diff --git a/frontend/pages/policies/PolicyPage/components/PolicyQueriesListWrapper/PolicyQueriesTableConfig.tsx b/frontend/pages/policies/PolicyPage/components/PolicyQueriesListWrapper/PolicyQueriesTableConfig.tsx new file mode 100644 index 0000000000..cd221e22f0 --- /dev/null +++ b/frontend/pages/policies/PolicyPage/components/PolicyQueriesListWrapper/PolicyQueriesTableConfig.tsx @@ -0,0 +1,89 @@ +/* eslint-disable react/prop-types */ +// disable this rule as it was throwing an error in Header and Cell component +// definitions for the selection row for some reason when we dont really need it. +import React from "react"; +import { memoize } from "lodash"; + +// @ts-ignore +import TextCell from "components/TableContainer/DataTable/TextCell/TextCell"; +import { IHostPolicyQuery } from "interfaces/host"; +import sortUtils from "utilities/sort"; +import PassIcon from "../../../../../../assets/images/icon-check-circle-green-16x16@2x.png"; +import FailIcon from "../../../../../../assets/images/icon-exclamation-circle-red-16x16@2x.png"; + +// TODO functions for paths math e.g., path={PATHS.MANAGE_HOSTS + getParams(cellProps.row.original)} + +interface IHeaderProps { + column: { + host: string; + isSortedDesc: boolean; + }; +} + +interface ICellProps { + cell: { + value: any; + }; + row: { + original: IHostPolicyQuery; + }; +} + +interface IDataColumn { + Header: ((props: IHeaderProps) => JSX.Element) | string; + Cell: (props: ICellProps) => JSX.Element; + title?: string; + accessor?: string; + disableHidden?: boolean; + disableSortBy?: boolean; + sortType?: string; +} + +// NOTE: cellProps come from react-table +// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties +const generateTableHeaders = (): IDataColumn[] => { + const tableHeaders: IDataColumn[] = [ + { + title: "Host", + Header: "Host", + disableSortBy: true, + accessor: "hostname", + Cell: (cellProps: ICellProps): JSX.Element => ( + + ), + }, + { + title: "Status", + Header: "Status", + disableSortBy: true, + accessor: "query_results", + Cell: (cellProps: ICellProps): JSX.Element => ( + <> + {cellProps.cell.value.length ? ( + <> + host passing + Yes + + ) : ( + <> + host passing + No + + )} + + ), + }, + ]; + return tableHeaders; +}; + +const generateDataSet = memoize( + (policyHostsList: IHostPolicyQuery[] = []): IHostPolicyQuery[] => { + policyHostsList = policyHostsList.sort((a, b) => + sortUtils.caseInsensitiveAsc(a.hostname, b.hostname) + ); + return policyHostsList; + } +); + +export { generateTableHeaders, generateDataSet }; diff --git a/frontend/pages/policies/PolicyPage/components/PolicyQueriesListWrapper/_styles.scss b/frontend/pages/policies/PolicyPage/components/PolicyQueriesListWrapper/_styles.scss new file mode 100644 index 0000000000..fa1684be7b --- /dev/null +++ b/frontend/pages/policies/PolicyPage/components/PolicyQueriesListWrapper/_styles.scss @@ -0,0 +1,140 @@ +.policies-queries-list-wrapper { + border-collapse: collapse; + + a { + color: $core-vibrant-blue; + font-size: $x-small; + text-decoration: none; + } + + &__wrapper { + border: 1px solid $ui-fleet-blue-15; + border-radius: 4px; + overflow: hidden; + margin-top: $pad-medium; + } + + .table-container { + margin-top: $pad-small; + } + + .table-container__header { + display: none; + } + + thead { + background-color: $ui-off-white; + border-bottom: 1px solid $ui-fleet-blue-15; + + th { + font-size: $x-small; + font-weight: $bold; + text-align: left; + padding: $pad-medium $pad-large; + } + + .hostname__header { + width: 70%; + } + } + + tbody td img { + width: 16px; + height: 16px; + vertical-align: sub; + padding-right: 4px; + } + + &__th-pack-name { + padding-left: 0; + text-align: left; + } + + &__select-all { + margin-bottom: 0; + } + + &__empty-table { + text-align: center; + font-size: $x-small; + color: $core-fleet-black; + } + + &__policy-count { + color: $core-fleet-black; + font-size: $x-small; + font-weight: $bold; + margin: 0 12px 0 0; + display: inline-block; + } +} + +.no-policies { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding-top: $pad-xxxlarge; + + a { + color: $core-vibrant-blue; + font-size: $x-small; + text-decoration: none; + } + + h1 { + font-size: $large; + font-weight: $regular; + line-height: normal; + letter-spacing: normal; + color: $core-fleet-black; + } + + h2 { + font-size: $small; + font-weight: $bold; + margin: 0 0 $pad-large; + line-height: 20px; + color: $core-fleet-black; + } + + &__inner { + display: flex; + flex-direction: column; + align-items: center; + + h1 { + font-size: $small; + font-weight: $bold; + margin-bottom: $pad-medium; + } + + img { + width: 322px; + } + + p { + color: $core-fleet-black; + font-weight: $regular; + font-size: $x-small; + margin: 0; + margin-bottom: $pad-large; + } + } + + &__inner-text { + width: 500px; + padding: $pad-xxlarge 0; + } + + &__bullet-text { + width: 455px; + text-align: left; + } +} + +.no-team-policy { + border: 1px solid #e2e4ea; + box-sizing: border-box; + border-radius: 8px; +} diff --git a/frontend/pages/policies/PolicyPage/components/PolicyQueriesListWrapper/index.ts b/frontend/pages/policies/PolicyPage/components/PolicyQueriesListWrapper/index.ts new file mode 100644 index 0000000000..ba92ba90e6 --- /dev/null +++ b/frontend/pages/policies/PolicyPage/components/PolicyQueriesListWrapper/index.ts @@ -0,0 +1 @@ +export { default } from "./PolicyQueriesListWrapper"; diff --git a/frontend/pages/policies/PolicyPage/components/QueryResults/QueryResults.tsx b/frontend/pages/policies/PolicyPage/components/QueryResults/QueryResults.tsx new file mode 100644 index 0000000000..a78213dfc5 --- /dev/null +++ b/frontend/pages/policies/PolicyPage/components/QueryResults/QueryResults.tsx @@ -0,0 +1,268 @@ +import React, { useState, useEffect } from "react"; +import { Tab, Tabs, TabList, TabPanel } from "react-tabs"; +import moment from "moment"; +import classnames from "classnames"; +import FileSaver from "file-saver"; +import { get } from "lodash"; + +// @ts-ignore +import convertToCSV from "utilities/convert_to_csv"; // @ts-ignore +import { ICampaign } from "interfaces/campaign"; +import { ITarget } from "interfaces/target"; + +import Button from "components/buttons/Button"; // @ts-ignore +import Spinner from "components/Spinner"; +import TabsWrapper from "components/TabsWrapper"; +import InfoBanner from "components/InfoBanner"; +import PolicyQueryListWrapper from "../PolicyQueriesListWrapper/PolicyQueriesListWrapper"; +import PolicyQueriesErrorsListWrapper from "../PolicyQueriesErrorsListWrapper/PolicyQueriesErrorsListWrapper"; + +import DownloadIcon from "../../../../../../assets/images/icon-download-12x12@2x.png"; + +interface IQueryResultsProps { + campaign: ICampaign; + isQueryFinished: boolean; + policyName?: string; + onRunQuery: (evt: React.MouseEvent) => void; + onStopQuery: (evt: React.MouseEvent) => void; + setSelectedTargets: (value: ITarget[]) => void; + goToQueryEditor: () => void; +} + +const baseClass = "query-results"; +const CSV_TITLE = "New Policy"; +const PAGE_TITLES = { + RUNNING: "Querying selected hosts", + FINISHED: "Query finished", +}; +const NAV_TITLES = { + RESULTS: "Results", + ERRORS: "Errors", +}; + +const QueryResults = ({ + campaign, + isQueryFinished, + policyName, + onRunQuery, + onStopQuery, + setSelectedTargets, + goToQueryEditor, +}: IQueryResultsProps): JSX.Element => { + const { hosts: hostsOnline, hosts_count: hostsCount, errors } = + campaign || {}; + + const totalHostsOnline = get(campaign, ["totals", "online"], 0); + const totalHostsOffline = get(campaign, ["totals", "offline"], 0); + const totalRowsCount = get(campaign, ["query_results", "length"], 0); + const onlineTotalText = `${totalRowsCount} result${ + totalRowsCount === 1 ? "" : "s" + }`; + const errorsTotalText = `${errors?.length || 0} result${ + errors?.length === 1 ? "" : "s" + }`; + + const [pageTitle, setPageTitle] = useState(PAGE_TITLES.RUNNING); + const [navTabIndex, setNavTabIndex] = useState(0); + + useEffect(() => { + if (isQueryFinished) { + setPageTitle(PAGE_TITLES.FINISHED); + } else { + setPageTitle(PAGE_TITLES.RUNNING); + } + }, [isQueryFinished]); + + const onExportQueryResults = (evt: React.MouseEvent) => { + evt.preventDefault(); + + if (hostsOnline) { + const hostsExport = hostsOnline.map((host) => { + return { + hostname: host.hostname, + status: + host.query_results && host.query_results.length ? "yes" : "no", + }; + }); + const csv = convertToCSV(hostsExport); + const formattedTime = moment(new Date()).format("MM-DD-YY hh-mm-ss"); + const filename = `${policyName || CSV_TITLE} (${formattedTime}).csv`; + const file = new global.window.File([csv], filename, { + type: "text/csv", + }); + + FileSaver.saveAs(file); + } + }; + + const onExportErrorsResults = (evt: React.MouseEvent) => { + evt.preventDefault(); + + if (errors) { + const csv = convertToCSV(errors); + + const formattedTime = moment(new Date()).format("MM-DD-YY hh-mm-ss"); + const filename = `${ + policyName || CSV_TITLE + } Errors (${formattedTime}).csv`; + const file = new global.window.File([csv], filename, { + type: "text/csv", + }); + + FileSaver.saveAs(file); + } + }; + + const onQueryDone = () => { + setSelectedTargets([]); + goToQueryEditor(); + }; + + const renderTable = () => { + const emptyResults = + !hostsOnline || !hostsOnline.length || !hostsCount.successful; + const hasNoResultsYet = !isQueryFinished && emptyResults; + const finishedWithNoResults = + isQueryFinished && (!hostsCount.successful || emptyResults); + + if (hasNoResultsYet) { + return ( +
+ +
+ ); + } + + if (finishedWithNoResults) { + return ( +

+ Your live query returned no results. + + Expecting to see results? Check to see if the hosts you targeted + reported “Online” or check out the “Errors” + table. + +

+ ); + } + + return ( +
+ + Host that responded with results are marked Yes. + Hosts that responded with no results are marked No. + + + +
+ ); + }; + + const renderErrorsTable = () => { + return ( +
+ + +
+ ); + }; + + const renderFinishedButtons = () => ( +
+ + +
+ ); + + const renderStopQueryButton = () => ( +
+ +
+ ); + + const firstTabClass = classnames("react-tabs__tab", "no-count", { + "errors-empty": !errors || errors?.length === 0, + }); + + return ( +
+
+

{pageTitle}

+
+ + Online: {totalHostsOnline} hosts / {onlineTotalText} + + + Offline: {totalHostsOffline} hosts / 0 results + + + Errors: {hostsCount.failed} hosts / {errorsTotalText} + +
+
+ {isQueryFinished ? renderFinishedButtons() : renderStopQueryButton()} + + setNavTabIndex(i)}> + + {NAV_TITLES.RESULTS} + + {errors?.length > 0 && ( + {errors.length} + )} + {NAV_TITLES.ERRORS} + + + {renderTable()} + {renderErrorsTable()} + + +
+ ); +}; + +export default QueryResults; diff --git a/frontend/pages/policies/PolicyPage/components/QueryResults/_styles.scss b/frontend/pages/policies/PolicyPage/components/QueryResults/_styles.scss new file mode 100644 index 0000000000..633c208f9b --- /dev/null +++ b/frontend/pages/policies/PolicyPage/components/QueryResults/_styles.scss @@ -0,0 +1,79 @@ +.query-results { + padding: $pad-xxxlarge $pad-xxlarge; + + .info-banner { + margin: 2rem auto 1.25rem; + } + + &__text-wrapper { + margin-top: 20px; + display: flex; + flex-direction: column; + font-size: $x-small; + + span:not(:last-of-type) { + margin-bottom: $pad-small; + } + } + + &__text-online { + &:before { + background-color: $ui-success; + border-radius: 100%; + content: " "; + display: inline-block; + height: 8px; + margin-right: $pad-small; + width: 8px; + } + } + + &__text-offline { + &:before { + background-color: $ui-fleet-black-25; + border-radius: 100%; + content: " "; + display: inline-block; + height: 8px; + margin-right: $pad-small; + width: 8px; + } + } + + &__text-error { + &:before { + background-color: $ui-error; + border-radius: 100%; + content: " "; + display: inline-block; + height: 8px; + margin-right: $pad-small; + width: 8px; + } + } + + &__btn-wrapper { + margin-top: $pad-large; + margin-bottom: $pad-xxlarge; + display: flex; + align-items: center; + + .button { + padding: $pad-small $pad-medium; + + &:not(:last-of-type) { + margin-right: $pad-small; + } + } + } + + &__export-btn { + img { + width: 13px; + height: 13px; + margin-left: 8px; + position: relative; + top: -2px; + } + } +} diff --git a/frontend/components/QueryResults/index.ts b/frontend/pages/policies/PolicyPage/components/QueryResults/index.ts similarity index 100% rename from frontend/components/QueryResults/index.ts rename to frontend/pages/policies/PolicyPage/components/QueryResults/index.ts diff --git a/frontend/pages/policies/PolicyPage/screens/RunQuery.tsx b/frontend/pages/policies/PolicyPage/screens/RunQuery.tsx index 2a52673012..19fb5e22f6 100644 --- a/frontend/pages/policies/PolicyPage/screens/RunQuery.tsx +++ b/frontend/pages/policies/PolicyPage/screens/RunQuery.tsx @@ -15,7 +15,7 @@ import { ICampaign, ICampaignState } from "interfaces/campaign"; import { IPolicy } from "interfaces/policy"; import { ITarget } from "interfaces/target"; -import QueryResults from "components/QueryResults"; +import QueryResults from "../components/QueryResults"; interface IRunQueryProps { storedPolicy: IPolicy | undefined; @@ -31,7 +31,7 @@ const RunQuery = ({ policyIdForEdit, setSelectedTargets, goToQueryEditor, -}: IRunQueryProps) => { +}: IRunQueryProps): JSX.Element => { const dispatch = useDispatch(); const [isQueryFinished, setIsQueryFinished] = useState(false); @@ -152,10 +152,9 @@ const RunQuery = ({ destroyCampaign(); try { - const isStoredQueryEdited = storedPolicy?.query !== lastEditedQueryBody; - - // because we are not using the saved query id if user edits the SQL - const queryId = isStoredQueryEdited ? null : policyIdForEdit; + // we do not want to run a stored query, + // instead always run provided query + const queryId = null; const returnedCampaign = await queryAPI.run({ query: lastEditedQueryBody, queryId, @@ -205,14 +204,16 @@ const RunQuery = ({ }, []); const { campaign } = campaignState; + return ( ); }; diff --git a/frontend/pages/policies/PolicyPage/screens/SelectTargets.tsx b/frontend/pages/policies/PolicyPage/screens/SelectTargets.tsx index 3f93d4e8a2..44eb1ff859 100644 --- a/frontend/pages/policies/PolicyPage/screens/SelectTargets.tsx +++ b/frontend/pages/policies/PolicyPage/screens/SelectTargets.tsx @@ -31,7 +31,6 @@ interface ITargetPillSelectorProps { interface ISelectTargetsProps { baseClass: string; selectedTargets: ITarget[]; - policyIdForEdit: number | null; goToQueryEditor: () => void; goToRunQuery: () => void; setSelectedTargets: React.Dispatch>; @@ -75,11 +74,10 @@ const TargetPillSelector = ({ const SelectTargets = ({ baseClass, selectedTargets, - policyIdForEdit, goToQueryEditor, goToRunQuery, setSelectedTargets, -}: ISelectTargetsProps) => { +}: ISelectTargetsProps): JSX.Element => { const [targetsTotalCount, setTargetsTotalCount] = useState( null ); @@ -99,7 +97,7 @@ const SelectTargets = ({ () => targetsAPI.loadAll({ query: searchText, - queryId: policyIdForEdit, + queryId: null, selected: formatSelectedTargetsForApi(selectedTargets) as any, }), { diff --git a/frontend/components/QueryResults/QueryResults.tsx b/frontend/pages/queries/QueryPage/components/QueryResults/QueryResults.tsx similarity index 99% rename from frontend/components/QueryResults/QueryResults.tsx rename to frontend/pages/queries/QueryPage/components/QueryResults/QueryResults.tsx index c8d64ac82f..82b8651008 100644 --- a/frontend/components/QueryResults/QueryResults.tsx +++ b/frontend/pages/queries/QueryPage/components/QueryResults/QueryResults.tsx @@ -17,7 +17,7 @@ import InputField from "components/forms/fields/InputField"; import QueryResultsRow from "components/queries/QueryResultsRow"; import Spinner from "components/Spinner"; import TabsWrapper from "components/TabsWrapper"; -import DownloadIcon from "../../../assets/images/icon-download-12x12@2x.png"; +import DownloadIcon from "../../../../../../assets/images/icon-download-12x12@2x.png"; interface IQueryResultsProps { campaign: ICampaign; diff --git a/frontend/components/QueryResults/_styles.scss b/frontend/pages/queries/QueryPage/components/QueryResults/_styles.scss similarity index 100% rename from frontend/components/QueryResults/_styles.scss rename to frontend/pages/queries/QueryPage/components/QueryResults/_styles.scss diff --git a/frontend/pages/queries/QueryPage/components/QueryResults/index.ts b/frontend/pages/queries/QueryPage/components/QueryResults/index.ts new file mode 100644 index 0000000000..4ff72f5be8 --- /dev/null +++ b/frontend/pages/queries/QueryPage/components/QueryResults/index.ts @@ -0,0 +1 @@ +export { default } from "./QueryResults"; diff --git a/frontend/pages/queries/QueryPage/screens/RunQuery.tsx b/frontend/pages/queries/QueryPage/screens/RunQuery.tsx index 45e04fae9e..7cfc32cddf 100644 --- a/frontend/pages/queries/QueryPage/screens/RunQuery.tsx +++ b/frontend/pages/queries/QueryPage/screens/RunQuery.tsx @@ -16,7 +16,7 @@ import { IQuery } from "interfaces/query"; import { ITarget } from "interfaces/target"; // import { useLastEditedQueryInfo } from "../helpers"; -import QueryResults from "components/QueryResults"; +import QueryResults from "../components/QueryResults"; interface IRunQueryProps { storedQuery: IQuery | undefined; diff --git a/frontend/redux/nodes/entities/campaigns/helpers.js b/frontend/redux/nodes/entities/campaigns/helpers.js index 64c65100dc..794f65d8e3 100644 --- a/frontend/redux/nodes/entities/campaigns/helpers.js +++ b/frontend/redux/nodes/entities/campaigns/helpers.js @@ -13,6 +13,7 @@ const updateCampaignStateFromResults = (campaign, { data }) => { const errors = campaign.errors || []; const hosts = campaign.hosts || []; const { host, rows, error } = data; + host.query_results = rows; const { hosts_count: hostsCount } = campaign; const newHosts = [...hosts, host]; const newQueryResults = [...queryResults, ...rows];