mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 17:08:53 +00:00
Add ability to run live queries on new and existing policies (#3230)
This commit is contained in:
parent
b397b3a68e
commit
2abae381e9
25 changed files with 961 additions and 29 deletions
1
changes/issue-2713-policy-live-queries
Normal file
1
changes/issue-2713-policy-live-queries
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Add ability to run live queries on new and existing policies
|
||||
|
|
@ -3,4 +3,5 @@
|
|||
border-radius: $border-radius;
|
||||
border: 1px solid #d9d9fe;
|
||||
background-color: $ui-vibrant-blue-10;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?: [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={`${noPolicyQueries}__inner`}>
|
||||
<p>No hosts are online.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${baseClass} ${
|
||||
canAddOrRemovePolicy ? "" : "hide-selection-column"
|
||||
}`}
|
||||
>
|
||||
<TableContainer
|
||||
resultsTitle={resultsTitle || "policies"}
|
||||
columns={generateTableHeaders()}
|
||||
data={generateDataSet(errorsList)}
|
||||
isLoading={isLoading}
|
||||
defaultSortHeader={"name"}
|
||||
defaultSortDirection={"asc"}
|
||||
manualSortBy
|
||||
showMarkAllPages={false}
|
||||
isAllPagesSelected={false}
|
||||
disablePagination
|
||||
primarySelectActionButtonVariant="text-icon"
|
||||
primarySelectActionButtonIcon="delete"
|
||||
primarySelectActionButtonText={"Delete"}
|
||||
emptyComponent={NoPolicyQueries}
|
||||
onQueryChange={noop}
|
||||
disableCount
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PoliciesListWrapper;
|
||||
|
|
@ -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 => (
|
||||
<TextCell value={cellProps.cell.value} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "OSQuery Version",
|
||||
Header: "OSQuery Version",
|
||||
disableSortBy: true,
|
||||
accessor: "osquery_version",
|
||||
Cell: (cellProps: ICellProps): JSX.Element => (
|
||||
<TextCell value={cellProps.cell.value} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Error",
|
||||
Header: "Error",
|
||||
disableSortBy: true,
|
||||
accessor: "error",
|
||||
Cell: (cellProps: ICellProps): JSX.Element => (
|
||||
<TextCell value={cellProps.cell.value} />
|
||||
),
|
||||
},
|
||||
];
|
||||
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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./PolicyQueriesErrorsListWrapper";
|
||||
|
|
@ -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 (
|
||||
<div className={`${noPolicyQueries}__inner`}>
|
||||
<p>No hosts are online.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${baseClass} ${
|
||||
canAddOrRemovePolicy ? "" : "hide-selection-column"
|
||||
}`}
|
||||
>
|
||||
<TableContainer
|
||||
resultsTitle={resultsTitle || "policies"}
|
||||
columns={generateTableHeaders()}
|
||||
data={generateDataSet(policyHostsList)}
|
||||
isLoading={isLoading}
|
||||
defaultSortHeader={"name"}
|
||||
defaultSortDirection={"asc"}
|
||||
manualSortBy
|
||||
showMarkAllPages={false}
|
||||
isAllPagesSelected={false}
|
||||
disablePagination
|
||||
primarySelectActionButtonVariant="text-icon"
|
||||
primarySelectActionButtonIcon="delete"
|
||||
primarySelectActionButtonText={"Delete"}
|
||||
emptyComponent={NoPolicyQueries}
|
||||
onQueryChange={noop}
|
||||
disableCount
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PoliciesListWrapper;
|
||||
|
|
@ -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 => (
|
||||
<TextCell value={cellProps.cell.value} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
Header: "Status",
|
||||
disableSortBy: true,
|
||||
accessor: "query_results",
|
||||
Cell: (cellProps: ICellProps): JSX.Element => (
|
||||
<>
|
||||
{cellProps.cell.value.length ? (
|
||||
<>
|
||||
<img alt="host passing" src={PassIcon} />
|
||||
<span className="header-icon-text">Yes</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<img alt="host passing" src={FailIcon} />
|
||||
<span className="header-icon-text">No</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
return tableHeaders;
|
||||
};
|
||||
|
||||
const generateDataSet = memoize(
|
||||
(policyHostsList: IHostPolicyQuery[] = []): IHostPolicyQuery[] => {
|
||||
policyHostsList = policyHostsList.sort((a, b) =>
|
||||
sortUtils.caseInsensitiveAsc(a.hostname, b.hostname)
|
||||
);
|
||||
return policyHostsList;
|
||||
}
|
||||
);
|
||||
|
||||
export { generateTableHeaders, generateDataSet };
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./PolicyQueriesListWrapper";
|
||||
|
|
@ -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<HTMLButtonElement>) => void;
|
||||
onStopQuery: (evt: React.MouseEvent<HTMLButtonElement>) => 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<string>(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<HTMLButtonElement>) => {
|
||||
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<HTMLButtonElement>) => {
|
||||
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 (
|
||||
<div className={`${baseClass}__loading-spinner`}>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (finishedWithNoResults) {
|
||||
return (
|
||||
<p className="no-results-message">
|
||||
Your live query returned no results.
|
||||
<span>
|
||||
Expecting to see results? Check to see if the hosts you targeted
|
||||
reported “Online” or check out the “Errors”
|
||||
table.
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}__results-table-container`}>
|
||||
<InfoBanner>
|
||||
Host that responded with results are marked <strong>Yes</strong>.
|
||||
Hosts that responded with no results are marked <strong>No</strong>.
|
||||
</InfoBanner>
|
||||
<Button
|
||||
className={`${baseClass}__export-btn`}
|
||||
onClick={onExportQueryResults}
|
||||
variant="text-link"
|
||||
>
|
||||
<>
|
||||
Export hosts <img alt="" src={DownloadIcon} />
|
||||
</>
|
||||
</Button>
|
||||
<PolicyQueryListWrapper
|
||||
isLoading={false}
|
||||
policyHostsList={hostsOnline}
|
||||
resultsTitle="hosts"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderErrorsTable = () => {
|
||||
return (
|
||||
<div className={`${baseClass}__error-table-container`}>
|
||||
<Button
|
||||
className={`${baseClass}__export-btn`}
|
||||
onClick={onExportErrorsResults}
|
||||
variant="text-link"
|
||||
>
|
||||
<>
|
||||
Export errors <img alt="" src={DownloadIcon} />
|
||||
</>
|
||||
</Button>
|
||||
<PolicyQueriesErrorsListWrapper
|
||||
isLoading={false}
|
||||
errorsList={errors}
|
||||
resultsTitle="errors"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFinishedButtons = () => (
|
||||
<div className={`${baseClass}__btn-wrapper`}>
|
||||
<Button
|
||||
className={`${baseClass}__done-btn`}
|
||||
onClick={onQueryDone}
|
||||
variant="brand"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
<Button
|
||||
className={`${baseClass}__run-btn`}
|
||||
onClick={onRunQuery}
|
||||
variant="blue-green"
|
||||
>
|
||||
Run again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderStopQueryButton = () => (
|
||||
<div className={`${baseClass}__btn-wrapper`}>
|
||||
<Button
|
||||
className={`${baseClass}__stop-btn`}
|
||||
onClick={onStopQuery}
|
||||
variant="alert"
|
||||
>
|
||||
<>
|
||||
<Spinner isInButton />
|
||||
Stop
|
||||
</>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const firstTabClass = classnames("react-tabs__tab", "no-count", {
|
||||
"errors-empty": !errors || errors?.length === 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<div className={`${baseClass}__wrapper`}>
|
||||
<h1>{pageTitle}</h1>
|
||||
<div className={`${baseClass}__text-wrapper`}>
|
||||
<span className={`${baseClass}__text-online`}>
|
||||
Online: {totalHostsOnline} hosts / {onlineTotalText}
|
||||
</span>
|
||||
<span className={`${baseClass}__text-offline`}>
|
||||
Offline: {totalHostsOffline} hosts / 0 results
|
||||
</span>
|
||||
<span className={`${baseClass}__text-error`}>
|
||||
Errors: {hostsCount.failed} hosts / {errorsTotalText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isQueryFinished ? renderFinishedButtons() : renderStopQueryButton()}
|
||||
<TabsWrapper>
|
||||
<Tabs selectedIndex={navTabIndex} onSelect={(i) => setNavTabIndex(i)}>
|
||||
<TabList>
|
||||
<Tab className={firstTabClass}>{NAV_TITLES.RESULTS}</Tab>
|
||||
<Tab disabled={!errors?.length}>
|
||||
{errors?.length > 0 && (
|
||||
<span className="count">{errors.length}</span>
|
||||
)}
|
||||
{NAV_TITLES.ERRORS}
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanel>{renderTable()}</TabPanel>
|
||||
<TabPanel>{renderErrorsTable()}</TabPanel>
|
||||
</Tabs>
|
||||
</TabsWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueryResults;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<boolean>(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 (
|
||||
<QueryResults
|
||||
campaign={campaign}
|
||||
isQueryFinished={isQueryFinished}
|
||||
onRunQuery={onRunQuery}
|
||||
onStopQuery={onStopQuery}
|
||||
isQueryFinished={isQueryFinished}
|
||||
setSelectedTargets={setSelectedTargets}
|
||||
goToQueryEditor={goToQueryEditor}
|
||||
policyName={storedPolicy?.name}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ interface ITargetPillSelectorProps {
|
|||
interface ISelectTargetsProps {
|
||||
baseClass: string;
|
||||
selectedTargets: ITarget[];
|
||||
policyIdForEdit: number | null;
|
||||
goToQueryEditor: () => void;
|
||||
goToRunQuery: () => void;
|
||||
setSelectedTargets: React.Dispatch<React.SetStateAction<ITarget[]>>;
|
||||
|
|
@ -75,11 +74,10 @@ const TargetPillSelector = ({
|
|||
const SelectTargets = ({
|
||||
baseClass,
|
||||
selectedTargets,
|
||||
policyIdForEdit,
|
||||
goToQueryEditor,
|
||||
goToRunQuery,
|
||||
setSelectedTargets,
|
||||
}: ISelectTargetsProps) => {
|
||||
}: ISelectTargetsProps): JSX.Element => {
|
||||
const [targetsTotalCount, setTargetsTotalCount] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
|
|
@ -99,7 +97,7 @@ const SelectTargets = ({
|
|||
() =>
|
||||
targetsAPI.loadAll({
|
||||
query: searchText,
|
||||
queryId: policyIdForEdit,
|
||||
queryId: null,
|
||||
selected: formatSelectedTargetsForApi(selectedTargets) as any,
|
||||
}),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./QueryResults";
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Reference in a new issue