Add ability to filter software by "Vulnerable" on the Host details page (#3875)

- Add new "Software" tab to host details page
- Add dropdown to filter vulnerable software
- Extend DataTable client-side filtering to accommodate filter inputs that are controlled by parent components
- Refactor host details software into separate component
This commit is contained in:
gillespi314 2022-01-31 16:41:54 -06:00 committed by GitHub
parent 4d5e3277ef
commit d101ec7c18
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 202 additions and 74 deletions

View File

@ -0,0 +1,2 @@
* Add new "Software" tab to Host details page
* Add ability to filter software by "Vulnerable" on the Host details page

View File

@ -34,6 +34,7 @@ const baseClass = "data-table-container";
interface IDataTableProps {
columns: Column[];
data: any;
filters?: Record<string, string | number | boolean>;
isLoading: boolean;
manualSortBy?: boolean;
sortHeader: any;
@ -69,6 +70,7 @@ const CLIENT_SIDE_DEFAULT_PAGE_SIZE = 20;
const DataTable = ({
columns: tableColumns,
data: tableData,
filters: tableFilters,
isLoading,
manualSortBy = false,
sortHeader,
@ -129,6 +131,7 @@ const DataTable = ({
previousPage,
setPageSize,
setFilter,
setAllFilters,
} = useTable(
{
columns,
@ -143,6 +146,29 @@ const DataTable = ({
manualSortBy,
// Initializes as false, but changes briefly to true on successful notification
autoResetSelectedRows: resetSelectedRows,
// Expands the enumerated `filterTypes` for react-table
// (see https://github.com/tannerlinsley/react-table/blob/master/src/filterTypes.js)
// with custom `filterTypes` defined for this `useTable` instance
filterTypes: React.useMemo(
() => ({
hasLength: (
// eslint-disable-next-line @typescript-eslint/no-shadow
rows: Row[],
columnIds: string[],
filterValue: boolean
) => {
return !filterValue
? rows
: rows?.filter((row) => {
return columnIds?.some((id) => row?.values?.[id]?.length);
});
},
}),
[]
),
// Expands the enumerated `sortTypes` for react-table
// (see https://github.com/tannerlinsley/react-table/blob/master/src/sortTypes.js)
// with custom `sortTypes` defined for this `useTable` instance
sortTypes: React.useMemo(
() => ({
caseInsensitive: (a: any, b: any, id: any) => {
@ -174,6 +200,16 @@ const DataTable = ({
const { sortBy, selectedRowIds } = tableState;
useEffect(() => {
if (tableFilters) {
const allFilters = Object.entries(tableFilters).map(([id, value]) => ({
id,
value,
}));
!!allFilters.length && setAllFilters(allFilters);
}
}, [tableFilters]);
// Listen for changes to filters if clientSideFilter is enabled
const setDebouncedClientFilter = useDebouncedCallback(

View File

@ -69,6 +69,7 @@ interface ITableContainerProps {
onPrimarySelectActionClick?: (selectedItemIds: number[]) => void;
customControl?: () => JSX.Element;
onSelectSingleRow?: (value: Row) => void;
filters?: Record<string, string | number | boolean>;
renderCount?: () => JSX.Element | null;
renderFooter?: () => JSX.Element | null;
}
@ -81,6 +82,7 @@ const DEFAULT_PAGE_INDEX = 0;
const TableContainer = ({
columns,
data,
filters,
isLoading,
manualSortBy = false,
defaultSortHeader = "name",
@ -342,6 +344,7 @@ const TableContainer = ({
isLoading={isLoading}
columns={columns}
data={data}
filters={filters}
manualSortBy={manualSortBy}
sortHeader={sortHeader}
sortDirection={sortDirection}

View File

@ -40,7 +40,6 @@ import InputField from "components/forms/fields/InputField";
import Spinner from "components/Spinner";
import Button from "components/buttons/Button";
import Modal from "components/Modal";
import SoftwareVulnerabilities from "pages/hosts/HostDetailsPage/SoftwareVulnCount";
import TableContainer from "components/TableContainer";
import TabsWrapper from "components/TabsWrapper";
import InfoBanner from "components/InfoBanner";
@ -59,6 +58,8 @@ import {
humanHostDetailUpdated,
secondsToHms,
} from "fleet/helpers";
import SoftwareTab from "./SoftwareTab/SoftwareTab";
// @ts-ignore
import SelectQueryModal from "./SelectQueryModal";
import TransferHostModal from "./TransferHostModal";
@ -67,13 +68,11 @@ import {
generatePolicyTableHeaders,
generatePolicyDataSet,
} from "./HostPoliciesTable/HostPoliciesTableConfig";
import generateSoftwareTableHeaders from "./SoftwareTable/SoftwareTableConfig";
import generateUsersTableHeaders from "./UsersTable/UsersTableConfig";
import {
generatePackTableHeaders,
generatePackDataSet,
} from "./PackTable/PackTableConfig";
import EmptySoftware from "./EmptySoftware";
import EmptyUsers from "./EmptyUsers";
import PolicyFailingCount from "./HostPoliciesTable/PolicyFailingCount";
import { isValidPolicyResponse } from "../ManageHostsPage/helpers";
@ -166,8 +165,7 @@ const HostDetailsPage = ({
const [showRefetchSpinner, setShowRefetchSpinner] = useState<boolean>(false);
const [packsState, setPacksState] = useState<IPackStats[]>();
const [scheduleState, setScheduleState] = useState<IQueryStats[]>();
const [softwareState, setSoftwareState] = useState<ISoftware[]>([]);
const [softwareSearchString, setSoftwareSearchString] = useState<string>("");
const [hostSoftware, setHostSoftware] = useState<ISoftware[]>([]);
const [usersState, setUsersState] = useState<{ username: string }[]>([]);
const [usersSearchString, setUsersSearchString] = useState<string>("");
const [copyMessage, setCopyMessage] = useState<string>("");
@ -293,7 +291,7 @@ const HostDetailsPage = ({
}
return; // exit early because refectch is pending so we can avoid unecessary steps below
}
setSoftwareState(returnedHost.software);
setHostSoftware(returnedHost.software);
setUsersState(returnedHost.users);
if (returnedHost.pack_stats) {
const packStatsByType = returnedHost.pack_stats.reduce(
@ -333,18 +331,6 @@ const HostDetailsPage = ({
});
}, [usersSearchString]);
useEffect(() => {
setSoftwareState(() => {
return (
host?.software.filter((softwareItem) => {
return softwareItem.name
.toLowerCase()
.includes(softwareSearchString.toLowerCase());
}) || []
);
});
}, [softwareSearchString]);
// returns a mixture of props from host
const normalizeEmptyValues = (hostData: any): { [key: string]: any } => {
return reduce(
@ -528,11 +514,6 @@ const HostDetailsPage = ({
}
};
const onSoftwareTableSearchChange = useCallback((queryData: any) => {
const { searchQuery } = queryData;
setSoftwareSearchString(searchQuery);
}, []);
const onUsersTableSearchChange = useCallback((queryData: any) => {
const { searchQuery } = queryData;
setUsersSearchString(searchQuery);
@ -946,51 +927,7 @@ const HostDetailsPage = ({
};
const renderSoftware = () => {
const tableHeaders = generateSoftwareTableHeaders();
return (
<div className="section section--software">
<p className="section__header">Software</p>
{host?.software.length === 0 ? (
<div className="results">
<p className="results__header">
No installed software detected on this host.
</p>
<p className="results__data">
Expecting to see software? Try again in a few seconds as the
system catches up.
</p>
</div>
) : (
<>
{host?.software && (
<SoftwareVulnerabilities softwareList={host?.software} />
)}
{host?.software && (
<TableContainer
columns={tableHeaders}
data={softwareState}
isLoading={isLoadingHost}
defaultSortHeader={"name"}
defaultSortDirection={"asc"}
inputPlaceHolder={"Filter software"}
onQueryChange={onSoftwareTableSearchChange}
resultsTitle={"software items"}
emptyComponent={EmptySoftware}
showMarkAllPages={false}
isAllPagesSelected={false}
searchable
wideSearch
filteredCount={softwareState.length}
isClientSidePagination
highlightOnHover
/>
)}
</>
)}
</div>
);
return <SoftwareTab isLoading={isLoadingHost} software={hostSoftware} />;
};
const renderRefetch = () => {
@ -1267,6 +1204,7 @@ const HostDetailsPage = ({
<Tabs>
<TabList>
<Tab>Details</Tab>
<Tab>Software</Tab>
<Tab>Schedule</Tab>
<Tab>Policies</Tab>
</TabList>
@ -1360,10 +1298,9 @@ const HostDetailsPage = ({
</div>
{renderLabels()}
</div>
{host?.software && renderSoftware()}
{renderUsers()}
</TabPanel>
<TabPanel>{renderSoftware()}</TabPanel>
<TabPanel>
{renderSchedule()}
{renderPacks()}

View File

@ -0,0 +1,106 @@
import React, { useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce/lib";
import { ISoftware } from "interfaces/software";
import { VULNERABLE_DROPDOWN_OPTIONS } from "utilities/constants";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import TableContainer from "components/TableContainer";
import EmptySoftware from "./EmptySoftware";
import SoftwareVulnCount from "./SoftwareVulnCount";
import generateSoftwareTableHeaders from "./SoftwareTableConfig";
const baseClass = "host-details";
interface ISoftwareTableProps {
isLoading: boolean;
software: ISoftware[];
}
const SoftwareTable = ({
isLoading,
software,
}: ISoftwareTableProps): JSX.Element => {
const [filterName, setFilterName] = useState("");
const [filterVuln, setFilterVuln] = useState(false);
const [filters, setFilters] = useState({
name: filterName,
vulnerabilities: filterVuln,
});
useEffect(() => {
setFilters({ name: filterName, vulnerabilities: filterVuln });
}, [filterName, filterVuln]);
const onQueryChange = useDebouncedCallback(
({ searchQuery }: { searchQuery: string }) => {
setFilterName(searchQuery);
},
300
);
const onVulnFilterChange = (value: boolean) => {
setFilterVuln(value);
};
const renderVulnFilterDropdown = () => {
return (
<Dropdown
value={filters.vulnerabilities}
className={`${baseClass}__vuln_dropdown`}
options={VULNERABLE_DROPDOWN_OPTIONS}
searchable={false}
onChange={onVulnFilterChange}
/>
);
};
const tableHeaders = generateSoftwareTableHeaders();
return (
<div className="section section--software">
<p className="section__header">Software</p>
{software.length ? (
<>
{software && <SoftwareVulnCount softwareList={software} />}
{software && (
<TableContainer
columns={tableHeaders}
data={software}
filters={filters}
isLoading={isLoading}
defaultSortHeader={"name"}
defaultSortDirection={"asc"}
inputPlaceHolder={"Filter software"}
onQueryChange={onQueryChange}
resultsTitle={"software items"}
emptyComponent={EmptySoftware}
showMarkAllPages={false}
isAllPagesSelected={false}
searchable
customControl={renderVulnFilterDropdown}
isClientSidePagination
isClientSideFilter
highlightOnHover
/>
)}
</>
) : (
<div className="results">
<p className="results__header">
No installed software detected on this host.
</p>
<p className="results__data">
Expecting to see software? Try again in a few seconds as the system
catches up.
</p>
</div>
)}
</div>
);
};
export default SoftwareTable;

View File

@ -35,6 +35,11 @@ interface IDataColumn {
disableHidden?: boolean;
disableSortBy?: boolean;
sortType?: string;
// Filter can be used by react-table to render a filter input inside the column header
Filter?: () => null | JSX.Element;
filter?: string; // one of the enumerated `filterTypes` for react-table
// (see https://github.com/tannerlinsley/react-table/blob/master/src/filterTypes.js)
// or one of the custom `filterTypes` defined for the `useTable` instance (see `DataTable`)
}
const TYPE_CONVERSION: Record<string, string> = {
@ -71,6 +76,8 @@ const generateSoftwareTableHeaders = (): IDataColumn[] => {
Header: "",
disableSortBy: true,
accessor: "vulnerabilities",
Filter: () => null, // input for this column filter outside of column header
filter: "hasLength", // filters out rows where vulnerabilities has no length if filter value is `true`
Cell: (cellProps) => {
const vulnerabilities = cellProps.cell.value;
if (isEmpty(vulnerabilities)) {
@ -113,6 +120,8 @@ const generateSoftwareTableHeaders = (): IDataColumn[] => {
/>
),
accessor: "name",
Filter: () => null, // input for this column filter is rendered outside of column header
filter: "text", // filters name text based on the user's search query
Cell: (cellProps) => {
const { name, bundle_identifier } = cellProps.row.original;
if (bundle_identifier) {

View File

@ -1,15 +1,17 @@
import React from "react";
import { ISoftware } from "interfaces/software";
import IssueIcon from "../../../../../assets/images/icon-issue-fleet-black-50-16x16@2x.png";
import IssueIcon from "../../../../../../assets/images/icon-issue-fleet-black-50-16x16@2x.png";
const baseClass = "software-vuln-count";
const SoftwareVulnCount = (vulnProps: {
interface ISoftwareVulnCountProps {
softwareList: ISoftware[];
}): JSX.Element | null => {
const { softwareList } = vulnProps;
}
const SoftwareVulnCount = ({
softwareList,
}: ISoftwareVulnCountProps): JSX.Element | null => {
const vulnCount = softwareList.reduce((sum, software) => {
return software.vulnerabilities
? sum + software.vulnerabilities.length

View File

@ -787,4 +787,37 @@
justify-content: flex-end;
margin: $pad-xxlarge 0 0;
}
.form-field--dropdown {
margin: 0;
}
&__vuln_dropdown {
width: 219px;
.Select-menu-outer {
width: 364px;
max-height: 310px;
.Select-menu {
max-height: none;
}
}
.Select-value {
padding-left: $pad-medium;
padding-right: $pad-medium;
&::before {
display: inline-block;
position: absolute;
padding: 5px 0 0 0; // centers spin
content: url(../assets/images/icon-filter-black-16x16@2x.png);
transform: scale(0.5);
height: 26px;
left: 2px;
}
}
.Select-value-label {
padding-left: $pad-large;
font-size: $small !important;
}
}
}