UI – Show percentages of passing and failing hosts when a live policy run completes (#18257)

## Addresses #16500
![Screenshot 2024-04-12 at 4 11
22 PM](https://github.com/fleetdm/fleet/assets/61553566/8f1cf17c-7378-4246-8f17-6f8fe3321b54)


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

---------

Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
This commit is contained in:
Jacob Shandling 2024-04-16 09:00:23 -07:00 committed by GitHub
parent ba6315f27a
commit de94299b65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 108 additions and 71 deletions

View file

@ -0,0 +1 @@
* When a live policy run finishes, display the percentages of passing and failing hosts to the user.

View file

@ -206,7 +206,7 @@ export interface IPackStats {
type: string;
}
export interface IHostPolicyQuery {
export interface IPolicyHostResponse {
id: number;
display_name: string;
query_results?: unknown[];

View file

@ -6,32 +6,25 @@ import { ICampaignError } from "interfaces/campaign";
import {
generateTableHeaders,
generateDataSet,
} from "./PolicyQueriesErrorsTableConfig";
} from "./PolicyErrorsTableConfig";
const baseClass = "policies-queries-table";
const noPolicyQueries = "no-policy-queries";
// TODO - this class is duplicated and styles are overlapping with PolicyResultsTable. Differentiate
// them clearly and encapsulate common styles.
const baseClass = "policy-results-table";
interface IPoliciesTableProps {
interface IPolicyErrorsTableProps {
errorsList: ICampaignError[];
isLoading: boolean;
resultsTitle?: string;
canAddOrDeletePolicy?: boolean;
}
const PoliciesTable = ({
const PolicyErrorsTable = ({
errorsList,
isLoading,
resultsTitle,
canAddOrDeletePolicy,
}: IPoliciesTableProps): JSX.Element => {
const NoPolicyQueries = () => {
return (
<div className={`${noPolicyQueries}__inner`}>
<p>No hosts are online.</p>
</div>
);
};
}: IPolicyErrorsTableProps): JSX.Element => {
return (
<div
className={`${baseClass} ${
@ -55,7 +48,11 @@ const PoliciesTable = ({
iconSvg: "trash",
variant: "text-icon",
}}
emptyComponent={NoPolicyQueries}
emptyComponent={() => (
<div className="no-hosts__inner">
<p>No hosts are online.</p>
</div>
)}
onQueryChange={noop}
disableCount
/>
@ -63,4 +60,4 @@ const PoliciesTable = ({
);
};
export default PoliciesTable;
export default PolicyErrorsTable;

View file

@ -1,4 +1,4 @@
.policies-queries-table {
.policy-results-table {
border-collapse: collapse;
&__wrapper {

View file

@ -0,0 +1 @@
export { default } from "./PolicyErrorsTable";

View file

@ -1 +0,0 @@
export { default } from "./PolicyQueriesErrorsTable";

View file

@ -1 +0,0 @@
export { default } from "./PolicyQueriesTable";

View file

@ -18,14 +18,16 @@ import Icon from "components/Icon/Icon";
import TabsWrapper from "components/TabsWrapper";
import InfoBanner from "components/InfoBanner";
import ShowQueryModal from "components/modals/ShowQueryModal";
import TooltipWrapper from "components/TooltipWrapper";
import QueryResultsHeading from "components/queries/queryResults/QueryResultsHeading";
import ResultsHeading from "components/queries/queryResults/QueryResultsHeading";
import AwaitingResults from "components/queries/queryResults/AwaitingResults";
import PolicyQueryTable from "../PolicyQueriesTable/PolicyQueriesTable";
import PolicyQueriesErrorsTable from "../PolicyQueriesErrorsTable/PolicyQueriesErrorsTable";
import PolicyResultsTable from "../PolicyResultsTable/PolicyResultsTable";
import PolicyQueriesErrorsTable from "../PolicyErrorsTable/PolicyErrorsTable";
import { getYesNoCounts } from "./helpers";
interface IQueryResultsProps {
interface IPolicyResultsProps {
campaign: ICampaign;
isQueryFinished: boolean;
policyName?: string;
@ -43,7 +45,7 @@ const NAV_TITLES = {
ERRORS: "Errors",
};
const QueryResults = ({
const PolicyResults = ({
campaign,
isQueryFinished,
policyName,
@ -52,10 +54,10 @@ const QueryResults = ({
setSelectedTargets,
goToQueryEditor,
targetsTotalCount,
}: IQueryResultsProps): JSX.Element => {
}: IPolicyResultsProps): JSX.Element => {
const { lastEditedQueryBody } = useContext(PolicyContext);
const { hosts: hostsOnline, hosts_count: hostsCount, errors } =
const { hosts: hostResponses, hosts_count: hostsCount, errors } =
campaign || {};
const totalRowsCount = get(campaign, ["hosts_count", "successful"], 0);
@ -63,11 +65,11 @@ const QueryResults = ({
const [navTabIndex, setNavTabIndex] = useState(0);
const [showQueryModal, setShowQueryModal] = useState(false);
const onExportQueryResults = (evt: React.MouseEvent<HTMLButtonElement>) => {
const onExportResults = (evt: React.MouseEvent<HTMLButtonElement>) => {
evt.preventDefault();
if (hostsOnline) {
const hostsExport = hostsOnline.map((host) => {
if (hostResponses) {
const hostsExport = hostResponses.map((host) => {
return {
host: host.display_name,
status:
@ -121,9 +123,7 @@ const QueryResults = ({
<Button
className={`${baseClass}__export-btn`}
onClick={
tableType === "errors"
? onExportErrorsResults
: onExportQueryResults
tableType === "errors" ? onExportErrorsResults : onExportResults
}
variant="text-icon"
>
@ -136,9 +136,27 @@ const QueryResults = ({
);
};
const renderTable = () => {
const renderPassFailPcts = () => {
const { yes: yesCt, no: noCt } = getYesNoCounts(hostResponses);
return (
<span className={`${baseClass}__results-pass-fail-pct`}>
{" "}
(Yes:{" "}
<TooltipWrapper tipContent={`${yesCt} host${yesCt !== 1 ? "s" : ""}`}>
{Math.ceil((yesCt / hostsCount.successful) * 100)}%
</TooltipWrapper>
, No:{" "}
<TooltipWrapper tipContent={`${noCt} host${noCt !== 1 ? "s" : ""}`}>
{Math.floor((noCt / hostsCount.successful) * 100)}%
</TooltipWrapper>
)
</span>
);
};
const renderResultsTable = () => {
const emptyResults =
!hostsOnline || !hostsOnline.length || !hostsCount.successful;
!hostResponses || !hostResponses.length || !hostsCount.successful;
const hasNoResultsYet = !isQueryFinished && emptyResults;
const finishedWithNoResults =
isQueryFinished && (!hostsCount.successful || emptyResults);
@ -167,16 +185,19 @@ const QueryResults = ({
Hosts that responded with no results are marked <strong>No</strong>.
</InfoBanner>
<div className={`${baseClass}__results-table-header`}>
<span className={`${baseClass}__results-count`}>
{totalRowsCount} result{totalRowsCount !== 1 && "s"}
<span className={`${baseClass}__results-meta`}>
<span className={`${baseClass}__results-count`}>
{totalRowsCount} result{totalRowsCount !== 1 && "s"}
</span>
{isQueryFinished && renderPassFailPcts()}
</span>
<div className={`${baseClass}__results-cta`}>
{renderTableButtons("results")}
</div>
</div>
<PolicyQueryTable
<PolicyResultsTable
isLoading={false}
policyHostsList={hostsOnline}
hostResponses={hostResponses}
resultsTitle="hosts"
/>
</div>
@ -211,7 +232,7 @@ const QueryResults = ({
return (
<div className={baseClass}>
<QueryResultsHeading
<ResultsHeading
respondedHosts={hostsCount.total}
targetsTotalCount={targetsTotalCount}
isQueryFinished={isQueryFinished}
@ -232,7 +253,7 @@ const QueryResults = ({
</span>
</Tab>
</TabList>
<TabPanel>{renderTable()}</TabPanel>
<TabPanel>{renderResultsTable()}</TabPanel>
<TabPanel>{renderErrorsTable()}</TabPanel>
</Tabs>
</TabsWrapper>
@ -246,4 +267,4 @@ const QueryResults = ({
);
};
export default QueryResults;
export default PolicyResults;

View file

@ -1,5 +1,4 @@
.query-results {
.info-banner {
margin: 2rem auto 1.25rem;
}
@ -46,9 +45,12 @@
margin-top: $pad-xlarge;
}
&__results-meta > * {
font-size: $x-small;
}
&__results-count,
&__error-count {
font-size: $x-small;
font-weight: $bold;
}
}

View file

@ -0,0 +1,19 @@
import { IPolicyHostResponse } from "interfaces/host";
export const getYesNoCounts = (hostResponses: IPolicyHostResponse[]) => {
const yesNoCounts = hostResponses.reduce(
(acc, hostResponse) => {
if (hostResponse.query_results?.length) {
acc.yes += 1;
} else {
acc.no += 1;
}
return acc;
},
{ yes: 0, no: 0 }
);
return yesNoCounts;
};
export default { getYesNoCounts };

View file

@ -0,0 +1 @@
export { default } from "./PolicyResults";

View file

@ -1,37 +1,30 @@
import React from "react";
import { noop } from "lodash";
import { IHostPolicyQuery } from "interfaces/host";
import { IPolicyHostResponse } from "interfaces/host";
import TableContainer from "components/TableContainer";
import {
generateTableHeaders,
generateDataSet,
} from "./PolicyQueriesTableConfig";
} from "./PolicyResultsTableConfig";
const baseClass = "policies-queries-table";
const noPolicyQueries = "no-policy-queries";
// TODO - this class is duplicated and styles are overlapping with PolicyErrorsTable. Differentiate
// them clearly and encapsulate common styles.
const baseClass = "policy-results-table";
interface IPoliciesTableProps {
policyHostsList: IHostPolicyQuery[];
interface IPolicyResultsTableProps {
hostResponses: IPolicyHostResponse[];
isLoading: boolean;
resultsTitle?: string;
canAddOrDeletePolicy?: boolean;
}
const PoliciesTable = ({
policyHostsList,
const PolicyResultsTable = ({
hostResponses,
isLoading,
resultsTitle,
canAddOrDeletePolicy,
}: IPoliciesTableProps): JSX.Element => {
const NoPolicyQueries = () => {
return (
<div className={`${noPolicyQueries}__inner`}>
<p>No hosts are online.</p>
</div>
);
};
}: IPolicyResultsTableProps): JSX.Element => {
return (
<div
className={`${baseClass} ${
@ -41,7 +34,7 @@ const PoliciesTable = ({
<TableContainer
resultsTitle={resultsTitle || "policies"}
columnConfigs={generateTableHeaders()}
data={generateDataSet(policyHostsList)}
data={generateDataSet(hostResponses)}
isLoading={isLoading}
defaultSortHeader="query_results"
defaultSortDirection="asc"
@ -54,7 +47,11 @@ const PoliciesTable = ({
iconSvg: "trash",
variant: "text-icon",
}}
emptyComponent={NoPolicyQueries}
emptyComponent={() => (
<div className="no-hosts__inner">
<p>No hosts are online.</p>
</div>
)}
onQueryChange={noop}
disableCount
/>
@ -62,4 +59,4 @@ const PoliciesTable = ({
);
};
export default PoliciesTable;
export default PolicyResultsTable;

View file

@ -10,7 +10,7 @@ import Icon from "components/Icon/Icon";
import TextCell from "components/TableContainer/DataTable/TextCell/TextCell";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell";
import { IHostPolicyQuery } from "interfaces/host";
import { IPolicyHostResponse } from "interfaces/host";
import sortUtils from "utilities/sort";
interface IHeaderProps {
@ -22,7 +22,7 @@ interface ICellProps {
value: string;
};
row: {
original: IHostPolicyQuery;
original: IPolicyHostResponse;
};
}
@ -87,7 +87,7 @@ const generateTableHeaders = (): IDataColumn[] => {
};
const generateDataSet = memoize(
(policyHostsList: IHostPolicyQuery[] = []): IHostPolicyQuery[] => {
(policyHostsList: IPolicyHostResponse[] = []): IPolicyHostResponse[] => {
policyHostsList = policyHostsList.sort((a, b) =>
sortUtils.caseInsensitiveAsc(a.display_name, b.display_name)
);

View file

@ -1,4 +1,4 @@
.policies-queries-table {
.policy-results-table {
border-collapse: collapse;
&__wrapper {

View file

@ -0,0 +1 @@
export { default } from "./PolicyResultsTable";

View file

@ -1 +0,0 @@
export { default } from "./QueryResults";

View file

@ -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 PolicyResults from "../components/PolicyResults";
interface IRunQueryProps {
storedPolicy: IPolicy | undefined;
@ -198,7 +198,7 @@ const RunQuery = ({
const { campaign } = campaignState;
return (
<QueryResults
<PolicyResults
campaign={campaign}
isQueryFinished={isQueryFinished}
onRunQuery={onRunQuery}