mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
UI – Show percentages of passing and failing hosts when a live policy run completes (#18257)
## Addresses #16500  - [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:
parent
ba6315f27a
commit
de94299b65
18 changed files with 108 additions and 71 deletions
1
changes/16500-policy-pass-fail-percentage
Normal file
1
changes/16500-policy-pass-fail-percentage
Normal file
|
|
@ -0,0 +1 @@
|
|||
* When a live policy run finishes, display the percentages of passing and failing hosts to the user.
|
||||
|
|
@ -206,7 +206,7 @@ export interface IPackStats {
|
|||
type: string;
|
||||
}
|
||||
|
||||
export interface IHostPolicyQuery {
|
||||
export interface IPolicyHostResponse {
|
||||
id: number;
|
||||
display_name: string;
|
||||
query_results?: unknown[];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
.policies-queries-table {
|
||||
.policy-results-table {
|
||||
border-collapse: collapse;
|
||||
|
||||
&__wrapper {
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./PolicyErrorsTable";
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./PolicyQueriesErrorsTable";
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./PolicyQueriesTable";
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./PolicyResults";
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
);
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
.policies-queries-table {
|
||||
.policy-results-table {
|
||||
border-collapse: collapse;
|
||||
|
||||
&__wrapper {
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./PolicyResultsTable";
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./QueryResults";
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue