Refactor Query/Policy UX (#4334)

This commit is contained in:
RachelElysia 2022-03-07 15:10:23 -05:00 committed by GitHub
parent 52db2812be
commit 02fa778788
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 202 additions and 118 deletions

View file

@ -0,0 +1 @@
* Improved UX around live query and live policy

View file

@ -257,7 +257,7 @@ describe(
cy.findByText(/run query/i).click({ force: true });
cy.findByText(/select targets/i).should("exist");
cy.findByText(/all hosts/i).click();
cy.findByText(/targets selected/i).should("exist"); // target count
cy.findByText(/hosts targeted/i).should("exist"); // target count
cy.findByText(/run/i).click();
cy.findByText(/querying selected hosts/i).should("exist"); // target count
});

View file

@ -267,7 +267,7 @@ describe(
cy.findByText(/run query/i).click({ force: true });
cy.findByText(/select targets/i).should("exist");
cy.findByText(/all hosts/i).click();
cy.findByText(/targets selected/i).should("exist"); // target count
cy.findByText(/hosts targeted/i).should("exist"); // target count
cy.findByText(/run/i).click();
cy.findByText(/querying selected hosts/i).should("exist"); // target count
});

View file

@ -82,6 +82,7 @@ const PolicyPage = ({
const [step, setStep] = useState<string>(QUERIES_PAGE_STEPS[1]);
const [selectedTargets, setSelectedTargets] = useState<ITarget[]>([]);
const [targetsTotalCount, setTargetsTotalCount] = useState<number>(0);
const [isLiveQueryRunnable, setIsLiveQueryRunnable] = useState<boolean>(true);
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
const [
@ -215,6 +216,8 @@ const PolicyPage = ({
goToQueryEditor: () => setStep(QUERIES_PAGE_STEPS[1]),
goToRunQuery: () => setStep(QUERIES_PAGE_STEPS[3]),
setSelectedTargets,
targetsTotalCount,
setTargetsTotalCount,
};
const step3Opts = {
@ -223,6 +226,7 @@ const PolicyPage = ({
policyIdForEdit,
setSelectedTargets,
goToQueryEditor: () => setStep(QUERIES_PAGE_STEPS[1]),
targetsTotalCount,
};
switch (step) {

View file

@ -124,6 +124,9 @@
img {
max-width: 12px;
}
.plus-icon {
padding-right: 3px;
}
.selector-name {
margin-left: 8px;
font-size: $x-small;
@ -153,10 +156,15 @@
&__targets-total-count {
margin-left: 16px;
font-size: $x-small;
display: flex;
span {
font-weight: 700;
}
.icon-tooltip {
margin-left: $pad-small;
}
}
&__page-loading {
.loading-spinner {

View file

@ -14,6 +14,7 @@ import Button from "components/buttons/Button"; // @ts-ignore
import Spinner from "components/Spinner";
import TabsWrapper from "components/TabsWrapper";
import InfoBanner from "components/InfoBanner";
import TooltipWrapper from "components/TooltipWrapper";
import PolicyQueryListWrapper from "../PolicyQueriesListWrapper/PolicyQueriesListWrapper";
import PolicyQueriesErrorsListWrapper from "../PolicyQueriesErrorsListWrapper/PolicyQueriesErrorsListWrapper";
@ -27,6 +28,7 @@ interface IQueryResultsProps {
onStopQuery: (evt: React.MouseEvent<HTMLButtonElement>) => void;
setSelectedTargets: (value: ITarget[]) => void;
goToQueryEditor: () => void;
targetsTotalCount: number;
}
const baseClass = "query-results";
@ -48,22 +50,27 @@ const QueryResults = ({
onStopQuery,
setSelectedTargets,
goToQueryEditor,
targetsTotalCount,
}: 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);
const [
targetsRespondedPercent,
setTargetsRespondedPercent,
] = useState<number>(0);
useEffect(() => {
const calculatePercent =
Math.round(
((totalRowsCount + errors?.length) / targetsTotalCount) * 100
) || 0;
setTargetsRespondedPercent(calculatePercent);
}, [totalRowsCount, errors]);
useEffect(() => {
if (isQueryFinished) {
@ -152,13 +159,16 @@ const QueryResults = ({
Host that responded with results are marked <strong>Yes</strong>.
Hosts that responded with no results are marked <strong>No</strong>.
</InfoBanner>
<span className={`${baseClass}__results-count`}>
{totalRowsCount} result{totalRowsCount !== 1 && "s"}
</span>
<Button
className={`${baseClass}__export-btn`}
onClick={onExportQueryResults}
variant="text-link"
>
<>
Export hosts <img alt="" src={DownloadIcon} />
Export results <img alt="" src={DownloadIcon} />
</>
</Button>
<PolicyQueryListWrapper
@ -173,6 +183,11 @@ const QueryResults = ({
const renderErrorsTable = () => {
return (
<div className={`${baseClass}__error-table-container`}>
{errors && (
<span className={`${baseClass}__error-count`}>
{errors.length} error{errors.length !== 1 && "s"}
</span>
)}
<Button
className={`${baseClass}__export-btn`}
onClick={onExportErrorsResults}
@ -234,15 +249,15 @@ const QueryResults = ({
<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>
<span>{targetsTotalCount}</span>&nbsp;hosts targeted&nbsp; (
{targetsRespondedPercent}%&nbsp;
<TooltipWrapper
tipContent={`
Hosts that respond may<br /> return results, errors, or <br />no results`}
>
responded
</TooltipWrapper>
)
</div>
</div>
{isQueryFinished ? renderFinishedButtons() : renderStopQueryButton()}

View file

@ -8,47 +8,19 @@
&__text-wrapper {
margin-top: 20px;
display: flex;
flex-direction: column;
flex-direction: row;
font-size: $x-small;
span {
font-weight: $bold;
}
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;
.icon-tooltip {
margin-left: $pad-small;
}
}
@ -76,4 +48,41 @@
top: -2px;
}
}
.table-container {
&__header {
display: none;
}
}
.data-table__wrapper {
overflow-x: scroll;
}
.data-table-container .data-table thead th {
min-width: 140px;
padding-left: 0px;
padding-right: $pad-large;
border-right: 0;
&:first-of-type {
padding-left: $pad-large;
}
}
.data-table-container .data-table tbody td {
padding-left: 0px;
padding-right: $pad-large;
&:first-of-type {
padding-left: $pad-large;
}
}
&__results-count,
&__error-count {
font-size: $x-small;
font-weight: $bold;
margin-right: $pad-medium;
}
}

View file

@ -22,6 +22,7 @@ interface IRunQueryProps {
selectedTargets: ITarget[];
setSelectedTargets: (value: ITarget[]) => void;
goToQueryEditor: () => void;
targetsTotalCount: number;
}
const RunQuery = ({
@ -29,6 +30,7 @@ const RunQuery = ({
selectedTargets,
setSelectedTargets,
goToQueryEditor,
targetsTotalCount,
}: IRunQueryProps): JSX.Element => {
const dispatch = useDispatch();
@ -212,6 +214,7 @@ const RunQuery = ({
setSelectedTargets={setSelectedTargets}
goToQueryEditor={goToQueryEditor}
policyName={storedPolicy?.name}
targetsTotalCount={targetsTotalCount}
/>
);
};

View file

@ -14,6 +14,7 @@ import { IHost } from "interfaces/host";
import TargetsInput from "components/TargetsInput";
import Button from "components/buttons/Button";
import Spinner from "components/Spinner";
import TooltipWrapper from "components/TooltipWrapper";
import PlusIcon from "../../../../../assets/images/icon-plus-purple-32x32@2x.png";
import CheckIcon from "../../../../../assets/images/icon-check-purple-32x32@2x.png";
import ExternalURLIcon from "../../../../../assets/images/icon-external-url-12x12@2x.png";
@ -33,6 +34,8 @@ interface ISelectTargetsProps {
goToQueryEditor: () => void;
goToRunQuery: () => void;
setSelectedTargets: React.Dispatch<React.SetStateAction<ITarget[]>>;
setTargetsTotalCount: React.Dispatch<React.SetStateAction<number>>;
targetsTotalCount: number;
}
const DEBOUNCE_DELAY = 500;
@ -60,7 +63,11 @@ const TargetPillSelector = ({
data-selected={isSelected}
onClick={(e) => onClick(entity)(e)}
>
<img alt="" src={isSelected ? CheckIcon : PlusIcon} />
<img
className={isSelected ? "check-icon" : "plus-icon"}
alt=""
src={isSelected ? CheckIcon : PlusIcon}
/>
<span className="selector-name">{displayText()}</span>
<span className="selector-count">{entity.count}</span>
</button>
@ -73,6 +80,7 @@ const SelectTargets = ({
goToQueryEditor,
goToRunQuery,
setSelectedTargets,
setTargetsTotalCount,
}: ISelectTargetsProps): JSX.Element => {
const [allHostsLabels, setAllHostsLabels] = useState<ILabel[] | null>(null);
const [platformLabels, setPlatformLabels] = useState<ILabel[] | null>(null);
@ -131,6 +139,10 @@ const SelectTargets = ({
}
);
useEffect(() => {
setTargetsTotalCount(targets?.targetsTotalCount || 0);
}, [targets]);
const handleClickCancel = () => {
setSelectedTargets([]);
goToQueryEditor();
@ -289,8 +301,15 @@ const SelectTargets = ({
<div className={`${baseClass}__targets-total-count`}>
{!!targets?.targetsTotalCount && (
<>
<span>{targets?.targetsTotalCount}</span> targets selected&nbsp; (
{targets?.targetsOnlinePercent}% online)
<span>{targets?.targetsTotalCount}</span>&nbsp;targets
selected&nbsp; ({targets?.targetsOnlinePercent}%&nbsp;
<TooltipWrapper
tipContent={`
Hosts are online if they<br /> have recently checked <br />into Fleet`}
>
online
</TooltipWrapper>
){" "}
</>
)}
</div>

View file

@ -64,6 +64,7 @@ const QueryPage = ({
);
const [step, setStep] = useState<string>(QUERIES_PAGE_STEPS[1]);
const [selectedTargets, setSelectedTargets] = useState<ITarget[]>([]);
const [targetsTotalCount, setTargetsTotalCount] = useState<number>(0);
const [isLiveQueryRunnable, setIsLiveQueryRunnable] = useState<boolean>(true);
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
const [
@ -195,6 +196,8 @@ const QueryPage = ({
goToQueryEditor: () => setStep(QUERIES_PAGE_STEPS[1]),
goToRunQuery: () => setStep(QUERIES_PAGE_STEPS[3]),
setSelectedTargets,
targetsTotalCount,
setTargetsTotalCount,
};
const step3Opts = {
@ -203,6 +206,7 @@ const QueryPage = ({
queryIdForEdit,
setSelectedTargets,
goToQueryEditor: () => setStep(QUERIES_PAGE_STEPS[1]),
targetsTotalCount,
};
switch (step) {

View file

@ -124,6 +124,9 @@
img {
max-width: 12px;
}
.plus-icon {
padding-right: 3px;
}
.selector-name {
margin-left: 8px;
font-size: $x-small;
@ -153,10 +156,15 @@
&__targets-total-count {
margin-left: 16px;
font-size: $x-small;
display: flex;
span {
font-weight: 700;
}
.icon-tooltip {
margin-left: $pad-small;
}
}
&__page-loading {
.loading-spinner {

View file

@ -11,10 +11,10 @@ import { ICampaign, ICampaignQueryResult } from "interfaces/campaign";
import { ITarget } from "interfaces/target";
import Button from "components/buttons/Button"; // @ts-ignore
import Spinner from "components/Spinner";
import TableContainer from "components/TableContainer";
import TabsWrapper from "components/TabsWrapper";
import TooltipWrapper from "components/TooltipWrapper";
import DownloadIcon from "../../../../../../assets/images/icon-download-12x12@2x.png";
import resultsTableHeaders from "./QueryResultsTableConfig";
@ -26,6 +26,7 @@ interface IQueryResultsProps {
onStopQuery: (evt: React.MouseEvent<HTMLButtonElement>) => void;
setSelectedTargets: (value: ITarget[]) => void;
goToQueryEditor: () => void;
targetsTotalCount: number;
}
const baseClass = "query-results";
@ -46,23 +47,27 @@ const QueryResults = ({
onStopQuery,
setSelectedTargets,
goToQueryEditor,
targetsTotalCount,
}: IQueryResultsProps): JSX.Element => {
const { hosts_count: hostsCount, query_results: queryResults, 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);
const [
targetsRespondedPercent,
setTargetsRespondedPercent,
] = useState<number>(0);
useEffect(() => {
const calculatePercent =
Math.round(
((totalRowsCount + errors?.length) / targetsTotalCount) * 100
) || 0;
setTargetsRespondedPercent(calculatePercent);
}, [totalRowsCount, errors]);
useEffect(() => {
if (isQueryFinished) {
@ -163,7 +168,10 @@ const QueryResults = ({
}
return (
<div>
<div className={`${baseClass}__results-table-container`}>
<span className={`${baseClass}__results-count`}>
{totalRowsCount} result{totalRowsCount !== 1 && "s"}
</span>
<Button
className={`${baseClass}__export-btn`}
onClick={onExportQueryResults}
@ -181,6 +189,11 @@ const QueryResults = ({
const renderErrorsTable = () => {
return (
<div className={`${baseClass}__error-table-container`}>
{errors && (
<span className={`${baseClass}__error-count`}>
{errors.length} error{errors.length !== 1 && "s"}
</span>
)}
<Button
className={`${baseClass}__export-btn`}
onClick={onExportErrorsResults}
@ -240,15 +253,15 @@ const QueryResults = ({
<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>
<span>{targetsTotalCount}</span>&nbsp;hosts targeted&nbsp; (
{targetsRespondedPercent}%&nbsp;
<TooltipWrapper
tipContent={`
Hosts that respond may<br /> return results, errors, or <br />no results`}
>
responded
</TooltipWrapper>
)
</div>
</div>
{isQueryFinished ? renderFinishedButtons() : renderStopQueryButton()}

View file

@ -4,47 +4,19 @@
&__text-wrapper {
margin-top: 20px;
display: flex;
flex-direction: column;
flex-direction: row;
font-size: $x-small;
span {
font-weight: $bold;
}
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;
.icon-tooltip {
margin-left: $pad-small;
}
}
@ -76,8 +48,6 @@
}
.table-container {
padding-top: $pad-large;
&__header {
display: none;
}
@ -106,4 +76,11 @@
padding-left: $pad-large;
}
}
&__results-count,
&__error-count {
font-size: $x-small;
font-weight: $bold;
margin-right: $pad-medium;
}
}

View file

@ -24,6 +24,7 @@ interface IRunQueryProps {
queryIdForEdit: number | null;
setSelectedTargets: (value: ITarget[]) => void;
goToQueryEditor: () => void;
targetsTotalCount: number;
}
const RunQuery = ({
@ -32,6 +33,7 @@ const RunQuery = ({
queryIdForEdit,
setSelectedTargets,
goToQueryEditor,
targetsTotalCount,
}: IRunQueryProps): JSX.Element | null => {
const dispatch = useDispatch();
@ -214,6 +216,7 @@ const RunQuery = ({
isQueryFinished={isQueryFinished}
setSelectedTargets={setSelectedTargets}
goToQueryEditor={goToQueryEditor}
targetsTotalCount={targetsTotalCount}
/>
);
};

View file

@ -19,6 +19,7 @@ import { IHost } from "interfaces/host";
import TargetsInput from "components/TargetsInput";
import Button from "components/buttons/Button";
import Spinner from "components/Spinner";
import TooltipWrapper from "components/TooltipWrapper";
import PlusIcon from "../../../../../assets/images/icon-plus-purple-32x32@2x.png";
import CheckIcon from "../../../../../assets/images/icon-check-purple-32x32@2x.png";
import ExternalURLIcon from "../../../../../assets/images/icon-external-url-12x12@2x.png";
@ -39,6 +40,8 @@ interface ISelectTargetsProps {
goToQueryEditor: () => void;
goToRunQuery: () => void;
setSelectedTargets: React.Dispatch<React.SetStateAction<ITarget[]>>;
setTargetsTotalCount: React.Dispatch<React.SetStateAction<number>>;
targetsTotalCount: number;
}
const DEBOUNCE_DELAY = 500;
@ -71,7 +74,11 @@ const TargetPillSelector = ({
data-selected={isSelected}
onClick={(e) => onClick(entity)(e)}
>
<img alt="" src={isSelected ? CheckIcon : PlusIcon} />
<img
className={isSelected ? "check-icon" : "plus-icon"}
alt=""
src={isSelected ? CheckIcon : PlusIcon}
/>
<span className="selector-name">{displayText()}</span>
<span className="selector-count">{entity.count}</span>
</button>
@ -85,6 +92,8 @@ const SelectTargets = ({
goToQueryEditor,
goToRunQuery,
setSelectedTargets,
setTargetsTotalCount,
targetsTotalCount,
}: ISelectTargetsProps): JSX.Element => {
const [allHostsLabels, setAllHostsLabels] = useState<ILabel[] | null>(null);
const [platformLabels, setPlatformLabels] = useState<ILabel[] | null>(null);
@ -145,6 +154,10 @@ const SelectTargets = ({
}
);
useEffect(() => {
setTargetsTotalCount(targets?.targetsTotalCount || 0);
}, [targets]);
const handleClickCancel = () => {
setSelectedTargets([]);
goToQueryEditor();
@ -307,8 +320,15 @@ const SelectTargets = ({
<div className={`${baseClass}__targets-total-count`}>
{!!targets?.targetsTotalCount && (
<>
<span>{targets?.targetsTotalCount}</span> targets selected&nbsp; (
{targets?.targetsOnlinePercent}% online)
<span>{targets?.targetsTotalCount}</span>&nbsp;hosts
targeted&nbsp; ({targets?.targetsOnlinePercent}%&nbsp;
<TooltipWrapper
tipContent={`
Hosts are online if they<br /> have recently checked <br />into Fleet`}
>
online
</TooltipWrapper>
){" "}
</>
)}
</div>