mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 17:05:18 +00:00
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:
parent
4d5e3277ef
commit
d101ec7c18
2
changes/issue-3054-3208-host-details-software
Normal file
2
changes/issue-3054-3208-host-details-software
Normal 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
|
@ -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(
|
||||
|
@ -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}
|
||||
|
@ -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()}
|
||||
|
106
frontend/pages/hosts/HostDetailsPage/SoftwareTab/SoftwareTab.tsx
Normal file
106
frontend/pages/hosts/HostDetailsPage/SoftwareTab/SoftwareTab.tsx
Normal 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;
|
@ -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) {
|
@ -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
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user