Refine manage software page features (#4040)

This commit is contained in:
gillespi314
2022-02-07 18:52:55 -06:00
committed by GitHub
parent 7d659e5a0a
commit ff00e26f14
8 changed files with 240 additions and 144 deletions
@@ -12,6 +12,7 @@ class Pagination extends PureComponent {
resultsPerPage: PropTypes.number,
onPaginationChange: PropTypes.func,
resultsOnCurrentPage: PropTypes.number,
disableNextPage: PropTypes.bool,
};
disablePrev = () => {
@@ -23,7 +24,8 @@ class Pagination extends PureComponent {
// but this seems to work when there is no data in the table.
return (
this.props.resultsOnCurrentPage === undefined ||
this.props.resultsOnCurrentPage < this.props.resultsPerPage
this.props.resultsOnCurrentPage < this.props.resultsPerPage ||
this.props.disableNextPage
);
};
@@ -51,6 +51,10 @@ interface ITableContainerProps {
searchable?: boolean;
wideSearch?: boolean;
disablePagination?: boolean;
disableNextPage?: boolean; // disableNextPage is a temporary workaround for the case
// where the number of items on the last page is equal to the page size.
// The old page controls for server-side pagination render a no results screen
// with a back button. This fix instead disables the next button in that case.
disableCount?: boolean;
primarySelectActionButtonVariant?: ButtonVariant;
primarySelectActionButtonIcon?: string;
@@ -105,6 +109,7 @@ const TableContainer = ({
searchable,
wideSearch,
disablePagination,
disableNextPage,
disableCount,
primarySelectActionButtonVariant = "brand",
primarySelectActionButtonIcon,
@@ -159,10 +164,13 @@ const TableContainer = ({
};
const hasPageIndexChangedRef = useRef(false);
const onPaginationChange = (newPage: number) => {
setPageIndex(newPage);
hasPageIndexChangedRef.current = true;
};
const onPaginationChange = useCallback(
(newPage: number) => {
setPageIndex(newPage);
hasPageIndexChangedRef.current = true;
},
[hasPageIndexChangedRef]
);
const onResultsCountChange = (resultsCount: number) => {
setClientFilterCount(resultsCount);
@@ -217,12 +225,14 @@ const TableContainer = ({
currentPage={pageIndex}
resultsPerPage={pageSize}
onPaginationChange={onPaginationChange}
disableNextPage={disableNextPage}
/>
);
}, [
data,
disablePagination,
isClientSidePagination,
data,
disableNextPage,
pageIndex,
pageSize,
onPaginationChange,
+14 -1
View File
@@ -214,6 +214,10 @@ export interface IConfigNested {
host_expiry_enabled: boolean;
host_expiry_window: number;
};
host_settings: {
enable_host_users: boolean;
enable_software_inventory: boolean;
};
agent_options: string;
update_interval: {
osquery_detail: number;
@@ -226,9 +230,18 @@ export interface IConfigNested {
expiration: string;
note: string;
};
vulnerability_settings: {
vulnerabilities: {
databases_path: string;
periodicity: number;
cpe_database_url: string;
cve_feed_prefix_url: string;
current_instance_checks: string;
disable_data_sync: boolean;
};
// Note: `vulnerability_settings` is deprecated and should not be used
// vulnerability_settings: {
// databases_path: string;
// };
webhook_settings: {
host_status_webhook: IWebhookHostStatus;
failing_policies_webhook: IWebhookFailingPolicies;
@@ -14,7 +14,6 @@ import { getConfig } from "redux/nodes/app/actions";
// @ts-ignore
import { renderFlash } from "redux/nodes/notifications/actions";
import configAPI from "services/entities/config";
import usersAPI, { IGetMeResponse } from "services/entities/users";
import softwareAPI, {
ISoftwareResponse,
ISoftwareCountResponse,
@@ -27,6 +26,7 @@ import {
import Button from "components/buttons/Button";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
// @ts-ignore
import Spinner from "components/Spinner";
import TableContainer, { ITableQueryData } from "components/TableContainer";
import TableDataError from "components/TableDataError";
@@ -37,7 +37,7 @@ import TeamsDropdownHeader, {
import ExternalLinkIcon from "../../../../assets/images/open-new-tab-12x12@2x.png";
import QuestionIcon from "../../../../assets/images/icon-question-16x16@2x.png";
import generateTableHeaders from "./SoftwareTableConfig";
import softwareTableHeaders from "./SoftwareTableConfig";
import ManageAutomationsModal from "./components/ManageAutomationsModal";
import EmptySoftware from "../components/EmptySoftware";
@@ -49,6 +49,9 @@ interface IManageSoftwarePageProps {
search: string;
};
}
interface IHeaderButtonsState extends ITeamsDropdownState {
isLoading: boolean;
}
const DEFAULT_SORT_DIRECTION = "desc";
const DEFAULT_SORT_HEADER = "hosts_count";
const PAGE_SIZE = 20;
@@ -71,8 +74,7 @@ const ManageSoftwarePage = ({
isGlobalMaintainer,
} = useContext(AppContext);
const [isLoadingSoftware, setIsLoadingSoftware] = useState(true);
const [isLoadingCount, setIsLoadingCount] = useState(true);
const [isSoftwareEnabled, setIsSoftwareEnabled] = useState<boolean>();
const [filterVuln, setFilterVuln] = useState(
location?.query?.vulnerable || false
);
@@ -92,36 +94,36 @@ const ManageSoftwarePage = ({
setFilterVuln(!!location.query.vulnerable);
}, [location]);
// TODO: combine string and object so only one array element in query key; figure out typing and
// destructuring for queryfn
useQuery(["me"], () => usersAPI.me(), {
onSuccess: ({ user, available_teams }: IGetMeResponse) => {
setCurrentUser(user);
setAvailableTeams(available_teams);
const { data: config } = useQuery(["config"], configAPI.loadAll, {
onSuccess: (data) => {
setIsSoftwareEnabled(data?.host_settings?.enable_software_inventory);
},
});
const { data: software, error: softwareError } = useQuery<
ISoftwareResponse,
Error
>(
const {
data: software,
error: softwareError,
isFetching: isFetchingSoftware,
} = useQuery<ISoftwareResponse, Error>(
[
"software",
{
pageIndex,
pageSize: PAGE_SIZE,
searchQuery,
sortDirection,
sortHeader,
teamId: currentTeam?.id,
vulnerable: !!location.query.vulnerable,
urlPath: location.pathname,
urlQueryString: location.search,
params: {
scope: "software",
pageIndex,
pageSize: PAGE_SIZE,
searchQuery,
sortDirection,
sortHeader,
teamId: currentTeam?.id,
vulnerable: !!location.query.vulnerable,
},
},
location.pathname,
location.search,
],
// TODO: figure out typing and destructuring for query key inside query function
() => {
setIsLoadingSoftware(true);
const params = {
page: pageIndex,
perPage: PAGE_SIZE,
@@ -134,35 +136,27 @@ const ManageSoftwarePage = ({
return softwareAPI.load(params);
},
{
// If keepPreviousData is enabled,
// useQuery no longer returns isLoading when making new calls after load
// So we manage our own load states
keepPreviousData: true,
staleTime: 30000, // stale time can be adjusted if fresher data is desired based on software inventory interval
onSuccess: () => {
setIsLoadingSoftware(false);
},
onError: () => {
setIsLoadingSoftware(false);
},
}
);
const { data: softwareCount, error: softwareCountError } = useQuery<
ISoftwareCountResponse,
Error,
number
>(
const {
data: softwareCount,
error: softwareCountError,
isFetching: isFetchingCount,
} = useQuery<ISoftwareCountResponse, Error, number>(
[
"softwareCount",
{
searchQuery,
vulnerable: !!location.query.vulnerable,
teamId: currentTeam?.id,
params: {
searchQuery,
vulnerable: !!location.query.vulnerable,
teamId: currentTeam?.id,
},
},
],
() => {
setIsLoadingCount(true);
return softwareAPI.count({
query: searchQuery,
vulnerable: !!location.query.vulnerable,
@@ -175,13 +169,6 @@ const ManageSoftwarePage = ({
refetchOnWindowFocus: false,
retry: 1,
select: (data) => data.count,
onSuccess: () => {
setIsLoadingCount(false);
},
onError: (err) => {
console.log("useQuery error: ", err);
setIsLoadingCount(false);
},
}
);
@@ -277,12 +264,12 @@ const ManageSoftwarePage = ({
};
const renderHeaderButtons = (
state: ITeamsDropdownState
state: IHeaderButtonsState
): JSX.Element | null => {
if (
canAddOrRemoveSoftwareWebhook &&
(!isPremiumTier || state.teamId === 0) &&
!isLoadingSoftwareVulnerabilitiesWebhook
(state.isGlobalAdmin || state.isGlobalMaintainer) &&
(!state.isPremiumTier || state.teamId === 0) &&
!state.isLoading
) {
return (
<Button
@@ -301,8 +288,8 @@ const ManageSoftwarePage = ({
return (
<p>
Search for installed software{" "}
{canAddOrRemoveSoftwareWebhook &&
(!isPremiumTier || state.teamId === 0) &&
{(state.isGlobalAdmin || state.isGlobalMaintainer) &&
(!state.isPremiumTier || state.teamId === 0) &&
"and manage automations for detected vulnerabilities (CVEs)"}{" "}
on{" "}
<b>
@@ -324,23 +311,29 @@ const ManageSoftwarePage = ({
onChange={onTeamSelect}
defaultTitle="Software"
description={renderHeaderDescription}
buttons={renderHeaderButtons}
buttons={(state) =>
renderHeaderButtons({
...state,
isLoading: isLoadingSoftwareVulnerabilitiesWebhook,
})
}
/>
);
}, [router, location, isLoadingSoftwareVulnerabilitiesWebhook]);
const renderSoftwareCount = useCallback(() => {
const count = softwareCount;
let lastUpdatedAt = software?.counts_updated_at;
if (!lastUpdatedAt || lastUpdatedAt === "0001-01-01T00:00:00Z") {
lastUpdatedAt = "never";
} else {
lastUpdatedAt = formatDistanceToNowStrict(new Date(lastUpdatedAt), {
addSuffix: true,
});
const lastUpdatedAt = software?.counts_updated_at
? formatDistanceToNowStrict(new Date(software?.counts_updated_at), {
addSuffix: true,
})
: software?.counts_updated_at;
if (!isSoftwareEnabled || !lastUpdatedAt) {
return null;
}
if (softwareCountError && !isLoadingCount) {
if (softwareCountError && !isFetchingCount) {
return (
<span className={`${baseClass}__count count-error`}>
Failed to load software count
@@ -352,7 +345,7 @@ const ManageSoftwarePage = ({
return count !== undefined ? (
<span
className={`${baseClass}__count ${
isLoadingCount ? "count-loading" : ""
isFetchingCount ? "count-loading" : ""
}`}
>
{`${count} software item${count === 1 ? "" : "s"}`}
@@ -387,7 +380,7 @@ const ManageSoftwarePage = ({
</span>
</span>
) : null;
}, [isLoadingCount, software, softwareCountError, softwareCount]);
}, [isFetchingCount, software, softwareCountError, softwareCount]);
// TODO: retool this with react-router location descriptor objects
const buildUrlQueryString = (queryString: string, vulnerable: boolean) => {
@@ -456,24 +449,38 @@ const ManageSoftwarePage = ({
);
};
return !availableTeams ? (
// TODO: Rework after backend is adjusted to differentiate empty search/filter results from
// collecting inventory
const isCollectingInventory =
!searchQuery &&
!filterVuln &&
!currentTeam?.id &&
!pageIndex &&
!software?.software &&
software?.counts_updated_at === null;
const isLastPage =
!!softwareCount &&
PAGE_SIZE * pageIndex + (software?.software?.length || 0) >= softwareCount;
return !availableTeams || !config ? (
<Spinner />
) : (
<div className={baseClass}>
<div className={`${baseClass}__wrapper body-wrap`}>
{renderHeader()}
{softwareError && !isLoadingSoftware ? (
{softwareError && !isFetchingSoftware ? (
<TableDataError />
) : (
<TableContainer
columns={generateTableHeaders()}
data={software?.software || []}
isLoading={isLoadingSoftware}
columns={softwareTableHeaders}
data={(isSoftwareEnabled && software?.software) || []}
isLoading={isFetchingSoftware || isFetchingCount}
resultsTitle={"software items"}
emptyComponent={() =>
EmptySoftware(
(filterVuln && "vulnerable") ||
(searchQuery && "search") ||
(!isSoftwareEnabled && "disabled") ||
(isCollectingInventory && "collecting") ||
"default"
)
}
@@ -483,6 +490,7 @@ const ManageSoftwarePage = ({
pageSize={PAGE_SIZE}
showMarkAllPages={false}
isAllPagesSelected={false}
disableNextPage={isLastPage}
searchable
inputPlaceHolder="Search software by name or vulnerabilities (CVEs)"
onQueryChange={onQueryChange}
@@ -14,12 +14,29 @@ import Chevron from "../../../../assets/images/icon-chevron-right-blue-16x16@2x.
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
interface ICellProps {
cell: {
value: any;
value: number | string | IVulnerability[];
};
row: {
original: ISoftware;
};
}
interface IStringCellProps extends ICellProps {
cell: {
value: string;
};
}
interface INumberCellProps extends ICellProps {
cell: {
value: number;
};
}
interface IVulnCellProps extends ICellProps {
cell: {
value: IVulnerability[];
};
}
interface IHeaderProps {
column: {
title: string;
@@ -36,28 +53,54 @@ interface IDataColumn {
disableSortBy?: boolean;
}
const condense = (vulnerabilities: IVulnerability[]) => {
const condensed =
(vulnerabilities?.length &&
vulnerabilities
.slice(-3)
.map((v) => v.cve)
.reverse()) ||
[];
return vulnerabilities.length > 3
? condensed.concat(`+${vulnerabilities.length - 3} more`)
: condensed;
};
const softwareTableHeaders = [
{
title: "Name",
Header: "Name",
disableSortBy: true,
accessor: "name",
Cell: (cellProps: ICellProps) => <TextCell value={cellProps.cell.value} />,
Cell: (cellProps: IStringCellProps) => (
<TextCell value={cellProps.cell.value} />
),
},
{
title: "Version",
Header: "Version",
disableSortBy: true,
accessor: "version",
Cell: (cellProps: ICellProps) => <TextCell value={cellProps.cell.value} />,
Cell: (cellProps: IStringCellProps) => (
<TextCell value={cellProps.cell.value} />
),
},
{
title: "Vulnerabilities",
Header: "Vulnerabilities",
disableSortBy: true,
accessor: "vulnerabilities",
Cell: (cellProps: ICellProps) => {
const vulnerabilities: IVulnerability[] = cellProps.cell.value;
Cell: (cellProps: IVulnCellProps) => {
const vulnerabilities = cellProps.cell.value || [];
const tooltipText = condense(vulnerabilities)?.map((value) => {
return (
<span key={`vuln_${value}`}>
{value}
<br />
</span>
);
});
if (!vulnerabilities?.length) {
return <span className="vulnerabilities text-muted">---</span>;
}
@@ -84,12 +127,7 @@ const softwareTableHeaders = [
data-html
>
<span className={`vulnerabilities tooltip__tooltip-text`}>
{vulnerabilities.map((v) => (
<span key={v.cve}>
{v.cve}
<br />
</span>
))}
{tooltipText}
</span>
</ReactTooltip>
</>
@@ -106,14 +144,16 @@ const softwareTableHeaders = [
),
disableSortBy: false,
accessor: "hosts_count",
Cell: (cellProps: ICellProps) => <TextCell value={cellProps.cell.value} />,
Cell: (cellProps: INumberCellProps) => (
<TextCell value={cellProps.cell.value} />
),
},
{
title: "Actions",
Header: "",
disableSortBy: true,
accessor: "id",
Cell: (cellProps: ICellProps) => {
Cell: (cellProps: INumberCellProps) => {
return (
<Link
to={`${PATHS.MANAGE_HOSTS}?software_id=${cellProps.cell.value}`}
@@ -127,8 +167,4 @@ const softwareTableHeaders = [
},
];
const generateTableHeaders = (): IDataColumn[] => {
return softwareTableHeaders;
};
export default generateTableHeaders;
export default softwareTableHeaders;
@@ -120,26 +120,41 @@
display: none;
}
&__empty-software {
margin: $pad-large auto 0;
margin: 80px auto 0;
display: flex;
flex-direction: column;
align-items: center;
h1 {
font-size: $small;
font-weight: $bold;
margin-bottom: $pad-medium;
}
.empty-software__inner {
display: flex;
flex-direction: column;
p {
color: $core-fleet-black;
font-weight: $regular;
font-size: $x-small;
margin: 0;
}
h1 {
font-size: $small;
font-weight: $bold;
margin-bottom: $pad-medium;
}
a {
color: $core-vibrant-blue;
font-size: $x-small;
font-weight: $bold;
text-decoration: none;
p {
color: $core-fleet-black;
font-weight: $regular;
font-size: $x-small;
margin: 0;
}
a {
color: $core-vibrant-blue;
font-size: $x-small;
font-weight: $bold;
text-decoration: none;
margin-left: 0;
}
img {
height: 12px;
width: 12px;
margin: 0;
}
}
}
&__count {
@@ -1,45 +1,56 @@
import React from "react";
import ExternalLinkIcon from "../../../../assets/images/open-new-tab-12x12@2x.png";
const baseClass = "manage-software-page";
type IEmptySoftware = "search" | "vulnerable" | "default" | "";
type IEmptySoftware = "disabled" | "collecting" | "default" | "";
const EmptySoftware = (message: IEmptySoftware): JSX.Element => {
switch (message) {
case "search":
case "disabled": {
return (
<div className={`${baseClass}__empty-software`}>
<h1>No software matches the current search criteria.</h1>
<p>
Expecting to see software? Try again in a few seconds as the system
catches up.
</p>
<div className="empty-software__inner">
<h1>Software inventory is disabled.</h1>
<p>
Check out the Fleet documentation on{" "}
<a
href="https://fleetdm.com/docs/using-fleet/vulnerability-processing#configuration"
target="_blank"
rel="noopener noreferrer"
>
how to configure software inventory{" "}
<img alt="External link" src={ExternalLinkIcon} />
</a>
</p>
</div>
</div>
);
case "vulnerable":
default:
}
case "collecting": {
return (
<div className={`${baseClass}__empty-software`}>
<h1>
No installed software{" "}
{message === "vulnerable"
? "with detected vulnerabilities"
: "detected"}
.
</h1>
<p>
Expecting to see software? Check out the Fleet documentation on{" "}
<a
href="https://fleetdm.com/docs/deploying/configuration#software-inventory"
target="_blank"
rel="noopener noreferrer"
>
how to configure software inventory
</a>
.
</p>
<div className="empty-software__inner">
<h1>Fleet is collecting software inventory.</h1>
<p>Try again in about 1 hour as the system catches up.</p>
</div>
</div>
);
}
default: {
return (
<div className={`${baseClass}__empty-software`}>
<div className="empty-software__inner">
<h1>No software matches the current search criteria.</h1>
<p>
Expecting to see software? Try again in about 1 hour as the system
catches up.
</p>
</div>
</div>
);
}
}
};
+2 -1
View File
@@ -2,11 +2,12 @@
import sendRequest from "services";
import endpoints from "fleet/endpoints";
import { IConfigNested } from "interfaces/config";
// TODO: add other methods from "fleet/entities/config"
export default {
loadAll: () => {
loadAll: (): Promise<IConfigNested> => {
const { CONFIG } = endpoints;
const path = `${CONFIG}`;