mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
#20385 See notes on that issue for API limitations (which is why Windows and macOS are the only platforms listed). Will move out of draft after adding the changes file and tests. # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
391 lines
12 KiB
TypeScript
391 lines
12 KiB
TypeScript
/**
|
|
software/titles Software tab > Table
|
|
software/versions Software tab > Table (version toggle on)
|
|
*/
|
|
|
|
import React, { useCallback, useMemo } from "react";
|
|
import { InjectedRouter } from "react-router";
|
|
import { Row } from "react-table";
|
|
|
|
import PATHS from "router/paths";
|
|
import { getNextLocationPath } from "utilities/helpers";
|
|
import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants";
|
|
import {
|
|
buildQueryStringFromParams,
|
|
convertParamsToSnakeCase,
|
|
} from "utilities/url";
|
|
import {
|
|
ISoftwareApiParams,
|
|
ISoftwareTitlesResponse,
|
|
ISoftwareVersionsResponse,
|
|
} from "services/entities/software";
|
|
import { ISoftwareTitle, ISoftwareVersion } from "interfaces/software";
|
|
|
|
// @ts-ignore
|
|
import Dropdown from "components/forms/fields/Dropdown";
|
|
import TableContainer from "components/TableContainer";
|
|
import Slider from "components/forms/fields/Slider";
|
|
import CustomLink from "components/CustomLink";
|
|
import LastUpdatedText from "components/LastUpdatedText";
|
|
import { ITableQueryData } from "components/TableContainer/TableContainer";
|
|
import TableCount from "components/TableContainer/TableCount";
|
|
import Button from "components/buttons/Button";
|
|
import Icon from "components/Icon";
|
|
import TooltipWrapper from "components/TooltipWrapper";
|
|
|
|
import EmptySoftwareTable from "pages/SoftwarePage/components/EmptySoftwareTable";
|
|
|
|
import generateTitlesTableConfig from "./SoftwareTitlesTableConfig";
|
|
import generateVersionsTableConfig from "./SoftwareVersionsTableConfig";
|
|
import {
|
|
ISoftwareDropdownFilterVal,
|
|
ISoftwareVulnFiltersParams,
|
|
SOFTWARE_TITLES_DROPDOWN_OPTIONS,
|
|
buildSoftwareFilterQueryParams,
|
|
buildSoftwareVulnFiltersQueryParams,
|
|
getVulnFilterRenderDetails,
|
|
} from "./helpers";
|
|
|
|
interface IRowProps extends Row {
|
|
original: {
|
|
id?: number;
|
|
};
|
|
}
|
|
|
|
type ITableConfigGenerator = (router: InjectedRouter, teamId?: number) => void;
|
|
|
|
const isSoftwareTitles = (
|
|
data?: ISoftwareTitlesResponse | ISoftwareVersionsResponse
|
|
): data is ISoftwareTitlesResponse => {
|
|
if (!data) return false;
|
|
return (data as ISoftwareTitlesResponse).software_titles !== undefined;
|
|
};
|
|
|
|
interface ISoftwareTableProps {
|
|
router: InjectedRouter;
|
|
data?: ISoftwareTitlesResponse | ISoftwareVersionsResponse;
|
|
showVersions: boolean;
|
|
installableSoftwareExists: boolean;
|
|
isSoftwareEnabled: boolean;
|
|
query: string;
|
|
perPage: number;
|
|
orderDirection: "asc" | "desc";
|
|
orderKey: string;
|
|
softwareFilter: ISoftwareDropdownFilterVal;
|
|
vulnFilters: ISoftwareVulnFiltersParams;
|
|
currentPage: number;
|
|
teamId?: number;
|
|
isLoading: boolean;
|
|
resetPageIndex: boolean;
|
|
onAddFiltersClick: () => void;
|
|
}
|
|
|
|
const baseClass = "software-table";
|
|
|
|
const SoftwareTable = ({
|
|
router,
|
|
data,
|
|
showVersions,
|
|
installableSoftwareExists,
|
|
isSoftwareEnabled,
|
|
query,
|
|
perPage,
|
|
orderDirection,
|
|
orderKey,
|
|
softwareFilter,
|
|
vulnFilters,
|
|
currentPage,
|
|
teamId,
|
|
isLoading,
|
|
resetPageIndex,
|
|
onAddFiltersClick,
|
|
}: ISoftwareTableProps) => {
|
|
const currentPath = showVersions
|
|
? PATHS.SOFTWARE_VERSIONS
|
|
: PATHS.SOFTWARE_TITLES;
|
|
|
|
const determineQueryParamChange = useCallback(
|
|
(newTableQuery: ITableQueryData) => {
|
|
const changedEntry = Object.entries(newTableQuery).find(([key, val]) => {
|
|
switch (key) {
|
|
case "searchQuery":
|
|
return val !== query;
|
|
case "sortDirection":
|
|
return val !== orderDirection;
|
|
case "sortHeader":
|
|
return val !== orderKey;
|
|
case "pageIndex":
|
|
return val !== currentPage;
|
|
default:
|
|
return false;
|
|
}
|
|
});
|
|
return changedEntry?.[0] ?? "";
|
|
},
|
|
[currentPage, orderDirection, orderKey, query]
|
|
);
|
|
|
|
const generateNewQueryParams = useCallback(
|
|
(newTableQuery: ITableQueryData, changedParam: string) => {
|
|
const newQueryParam: Record<string, string | number | undefined> = {
|
|
query: newTableQuery.searchQuery,
|
|
team_id: teamId,
|
|
order_direction: newTableQuery.sortDirection,
|
|
order_key: newTableQuery.sortHeader,
|
|
page: changedParam === "pageIndex" ? newTableQuery.pageIndex : 0,
|
|
...buildSoftwareVulnFiltersQueryParams(vulnFilters),
|
|
};
|
|
if (softwareFilter === "installableSoftware") {
|
|
newQueryParam.available_for_install = true.toString();
|
|
}
|
|
if (softwareFilter === "selfServiceSoftware") {
|
|
newQueryParam.self_service = true.toString();
|
|
}
|
|
|
|
return newQueryParam;
|
|
},
|
|
[softwareFilter, teamId, vulnFilters]
|
|
);
|
|
|
|
// NOTE: this is called once on initial render and every time the query changes
|
|
const onQueryChange = useCallback(
|
|
(newTableQuery: ITableQueryData) => {
|
|
// we want to determine which query param has changed in order to
|
|
// reset the page index to 0 if any other param has changed.
|
|
const changedParam = determineQueryParamChange(newTableQuery);
|
|
|
|
// if nothing has changed, don't update the route. this can happen when
|
|
// this handler is called on the inital render. Can also happen when
|
|
// the filter dropdown is changed. That is handled on the onChange handler
|
|
// for the dropdown.
|
|
if (changedParam === "") return;
|
|
|
|
const newRoute = getNextLocationPath({
|
|
pathPrefix: currentPath,
|
|
routeTemplate: "",
|
|
queryParams: generateNewQueryParams(newTableQuery, changedParam),
|
|
});
|
|
|
|
router.replace(newRoute);
|
|
},
|
|
[determineQueryParamChange, generateNewQueryParams, router, currentPath]
|
|
);
|
|
|
|
let tableData: ISoftwareTitle[] | ISoftwareVersion[] | undefined;
|
|
let generateTableConfig: ITableConfigGenerator;
|
|
|
|
if (data === undefined) {
|
|
tableData;
|
|
generateTableConfig = () => [];
|
|
} else if (isSoftwareTitles(data)) {
|
|
tableData = data.software_titles;
|
|
generateTableConfig = generateTitlesTableConfig;
|
|
} else {
|
|
tableData = data.software;
|
|
generateTableConfig = generateVersionsTableConfig;
|
|
}
|
|
|
|
const softwareTableHeaders = useMemo(() => {
|
|
if (!data) return [];
|
|
return generateTableConfig(router, teamId);
|
|
}, [generateTableConfig, data, router, teamId]);
|
|
|
|
// Determines if a user should be able to filter or search in the table
|
|
const hasData = tableData && tableData.length > 0;
|
|
const hasQuery = query !== "";
|
|
const hasSoftwareFilter = softwareFilter !== "allSoftware";
|
|
const hasVersionFilter = showVersions;
|
|
const vulnFilterDetails = getVulnFilterRenderDetails(vulnFilters);
|
|
const hasVulnFilters = vulnFilterDetails.filterCount > 0;
|
|
|
|
const showFilterHeaders =
|
|
isSoftwareEnabled &&
|
|
(hasData ||
|
|
hasQuery ||
|
|
hasSoftwareFilter ||
|
|
hasVersionFilter ||
|
|
hasVulnFilters);
|
|
|
|
const handleShowVersionsToggle = () => {
|
|
const queryParams: Record<string, string | number | boolean | undefined> = {
|
|
query,
|
|
team_id: teamId,
|
|
order_direction: orderDirection,
|
|
order_key: orderKey,
|
|
page: 0, // resets page index
|
|
...buildSoftwareFilterQueryParams("allSoftware"), // Reset to all software
|
|
...buildSoftwareVulnFiltersQueryParams(vulnFilters),
|
|
};
|
|
|
|
router.replace(
|
|
getNextLocationPath({
|
|
pathPrefix: showVersions
|
|
? PATHS.SOFTWARE_TITLES
|
|
: PATHS.SOFTWARE_VERSIONS,
|
|
routeTemplate: "",
|
|
queryParams,
|
|
})
|
|
);
|
|
};
|
|
|
|
const handleCustomFilterDropdownChange = (
|
|
value: ISoftwareDropdownFilterVal
|
|
) => {
|
|
const queryParams: ISoftwareApiParams = {
|
|
query,
|
|
teamId,
|
|
orderDirection,
|
|
orderKey,
|
|
page: 0, // resets page index
|
|
...buildSoftwareVulnFiltersQueryParams(vulnFilters),
|
|
...buildSoftwareFilterQueryParams(value),
|
|
};
|
|
|
|
router.replace(
|
|
getNextLocationPath({
|
|
pathPrefix: currentPath,
|
|
routeTemplate: "",
|
|
queryParams: convertParamsToSnakeCase(queryParams),
|
|
})
|
|
);
|
|
};
|
|
|
|
const handleRowSelect = (row: IRowProps) => {
|
|
const queryParams = showVersions
|
|
? buildQueryStringFromParams({
|
|
software_version_id: row.original.id,
|
|
team_id: teamId,
|
|
})
|
|
: buildQueryStringFromParams({
|
|
software_title_id: row.original.id,
|
|
team_id: teamId,
|
|
});
|
|
|
|
const path = `${PATHS.MANAGE_HOSTS}?${queryParams}`;
|
|
|
|
router.push(path);
|
|
};
|
|
|
|
const renderSoftwareCount = () => {
|
|
if (!tableData || !data) return null;
|
|
|
|
return (
|
|
<>
|
|
<TableCount name="items" count={data?.count} />
|
|
{tableData && data?.counts_updated_at && (
|
|
<LastUpdatedText
|
|
lastUpdatedAt={data.counts_updated_at}
|
|
customTooltipText={
|
|
<>
|
|
The last time software data was <br />
|
|
updated, including vulnerabilities <br />
|
|
and host counts.
|
|
</>
|
|
}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const renderCustomControls = () => {
|
|
return (
|
|
<div className={`${baseClass}__filter-controls`}>
|
|
{!showVersions && ( // Hidden when viewing versions table
|
|
<Dropdown
|
|
value={softwareFilter}
|
|
className={`${baseClass}__vuln_dropdown`}
|
|
options={SOFTWARE_TITLES_DROPDOWN_OPTIONS}
|
|
searchable={false}
|
|
onChange={handleCustomFilterDropdownChange}
|
|
iconName="filter"
|
|
/>
|
|
)}
|
|
<Slider
|
|
value={showVersions}
|
|
onChange={handleShowVersionsToggle}
|
|
inactiveText="Show versions"
|
|
activeText="Show versions"
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderCustomFiltersButton = () => {
|
|
return (
|
|
<TooltipWrapper
|
|
className={`${baseClass}__filters`}
|
|
position="left"
|
|
underline={false}
|
|
showArrow
|
|
tipOffset={12}
|
|
tipContent={vulnFilterDetails.tooltipText}
|
|
disableTooltip={!hasVulnFilters}
|
|
>
|
|
<Button variant="text-link" onClick={onAddFiltersClick}>
|
|
<Icon name="filter" color="core-fleet-blue" />
|
|
<span>{vulnFilterDetails.buttonText}</span>
|
|
</Button>
|
|
</TooltipWrapper>
|
|
);
|
|
};
|
|
|
|
const renderTableHelpText = () => (
|
|
<div>
|
|
Seeing unexpected software or vulnerabilities?{" "}
|
|
<CustomLink
|
|
url={GITHUB_NEW_ISSUE_LINK}
|
|
text="File an issue on GitHub"
|
|
newTab
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className={baseClass}>
|
|
<TableContainer
|
|
columnConfigs={softwareTableHeaders}
|
|
data={tableData ?? []}
|
|
isLoading={isLoading}
|
|
resultsTitle="items"
|
|
emptyComponent={() => (
|
|
<EmptySoftwareTable
|
|
softwareFilter={softwareFilter}
|
|
vulnFilters={vulnFilters}
|
|
isSoftwareDisabled={!isSoftwareEnabled}
|
|
noSearchQuery={query === ""}
|
|
installableSoftwareExists={installableSoftwareExists}
|
|
/>
|
|
)}
|
|
defaultSortHeader={orderKey}
|
|
defaultSortDirection={orderDirection}
|
|
defaultPageIndex={currentPage}
|
|
defaultSearchQuery={query}
|
|
manualSortBy
|
|
pageSize={perPage}
|
|
showMarkAllPages={false}
|
|
isAllPagesSelected={false}
|
|
disableNextPage={!data?.meta.has_next_results}
|
|
searchable={showFilterHeaders}
|
|
inputPlaceHolder="Search by name or vulnerability (CVE)"
|
|
onQueryChange={onQueryChange}
|
|
// additionalQueries serves as a trigger for the useDeepEffect hook
|
|
// to fire onQueryChange for events happening outside of
|
|
// the TableContainer.
|
|
// additionalQueries={softwareFilter}
|
|
customControl={showFilterHeaders ? renderCustomControls : undefined}
|
|
customFiltersButton={
|
|
showFilterHeaders ? renderCustomFiltersButton : undefined
|
|
}
|
|
stackControls
|
|
renderCount={renderSoftwareCount}
|
|
renderTableHelpText={renderTableHelpText}
|
|
disableMultiRowSelect
|
|
onSelectSingleRow={handleRowSelect}
|
|
resetPageIndex={resetPageIndex}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SoftwareTable;
|