diff --git a/changes/15226-hosts-filter-software b/changes/15226-hosts-filter-software new file mode 100644 index 0000000000..19cf0805f3 --- /dev/null +++ b/changes/15226-hosts-filter-software @@ -0,0 +1 @@ +- Updated manage hosts UI to filter hosts by `software_version_id` and `software_title_id`. diff --git a/changes/issue-15224-15225-implement-new-software-pages b/changes/issue-15224-15225-implement-new-software-pages new file mode 100644 index 0000000000..a376105647 --- /dev/null +++ b/changes/issue-15224-15225-implement-new-software-pages @@ -0,0 +1,2 @@ +- add new software pages to fleet UI. Includes software titles, software versions, software title + details and software version details. diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index ac7eadd9f8..8c647ac1b4 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -691,6 +691,7 @@ spec: versions: - id: 0 version: 0.0.3 + vulnerabilities: null versions_count: 1 ` @@ -741,7 +742,8 @@ spec: "versions": [ { "id": 0, - "version": "0.0.3" + "version": "0.0.3", + "vulnerabilities": null } ] } diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts index 7bd61786e0..4067212381 100644 --- a/frontend/__mocks__/softwareMock.ts +++ b/frontend/__mocks__/softwareMock.ts @@ -1,4 +1,16 @@ -import { ISoftware } from "interfaces/software"; +import { + ISoftware, + ISoftwareVersion, + ISoftwareTitle, + ISoftwareVulnerability, + ISoftwareTitleVersion, +} from "interfaces/software"; +import { + ISoftwareTitlesResponse, + ISoftwareTitleResponse, + ISoftwareVersionsResponse, + ISoftwareVersionResponse, +} from "services/entities/software"; const DEFAULT_SOFTWARE_MOCK: ISoftware = { hosts_count: 1, @@ -12,8 +24,125 @@ const DEFAULT_SOFTWARE_MOCK: ISoftware = { bundle_identifier: "com.app.mock", }; -const createMockSoftware = (overrides?: Partial): ISoftware => { +export const createMockSoftware = ( + overrides?: Partial +): ISoftware => { return { ...DEFAULT_SOFTWARE_MOCK, ...overrides }; }; -export default createMockSoftware; +const DEFAULT_SOFTWARE_TITLE_VERSION_MOCK = { + id: 1, + version: "1.0.0", + vulnerabilities: ["CVE-2020-0001"], +}; + +export const createMockSoftwareTitleVersion = ( + overrides?: Partial +): ISoftwareTitleVersion => { + return { ...DEFAULT_SOFTWARE_TITLE_VERSION_MOCK, ...overrides }; +}; + +const DEFAULT_SOFTWARE_TITLE_MOCK: ISoftwareTitle = { + id: 1, + name: "mock software 1.app", + versions_count: 1, + source: "apps", + hosts_count: 1, + browser: "chrome", + versions: [createMockSoftwareTitleVersion()], +}; + +export const createMockSoftwareTitle = ( + overrides?: Partial +): ISoftwareTitle => { + return { ...DEFAULT_SOFTWARE_TITLE_MOCK, ...overrides }; +}; + +const DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK: ISoftwareTitlesResponse = { + counts_updated_at: "2020-01-01T00:00:00.000Z", + count: 1, + software_titles: [createMockSoftwareTitle()], + meta: { + has_next_results: false, + has_previous_results: false, + }, +}; + +export const createMockSoftwareTitlesReponse = ( + overrides?: Partial +): ISoftwareTitlesResponse => { + return { ...DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK, ...overrides }; +}; + +const DEFAULT_SOFTWARE_VULNERABILITY_MOCK = { + cve: "CVE-2020-0001", + details_link: "https://test.com", + cvss_score: 9, + epss_probability: 0.8, + cisa_known_exploit: false, + cve_published: "2020-01-01T00:00:00.000Z", + cve_description: "test description", + resolved_in_version: "1.2.3", +}; + +export const createMockSoftwareVulnerability = ( + overrides?: Partial +): ISoftwareVulnerability => { + return { ...DEFAULT_SOFTWARE_VULNERABILITY_MOCK, ...overrides }; +}; + +const DEFAULT_SOFTWARE_VERSION_MOCK: ISoftwareVersion = { + id: 1, + name: "test.app", + version: "1.2.3", + bundle_identifier: "com.test.Desktop", + source: "test_package", + release: "1", + vendor: "test_vendor", + arch: "x86_64", + generated_cpe: "cpe:test:app:1.2.3", + vulnerabilities: [createMockSoftwareVulnerability()], + hosts_count: 1, +}; + +export const createMockSoftwareVersion = ( + overrides?: Partial +): ISoftwareVersion => { + return { ...DEFAULT_SOFTWARE_VERSION_MOCK, ...overrides }; +}; + +const DEFAULT_SOFTWARE_VERSIONS_RESPONSE_MOCK: ISoftwareVersionsResponse = { + counts_updated_at: "2020-01-01T00:00:00.000Z", + count: 1, + software: [createMockSoftwareVersion()], + meta: { + has_next_results: false, + has_previous_results: false, + }, +}; + +export const createMockSoftwareVersionsReponse = ( + overrides?: Partial +): ISoftwareVersionsResponse => { + return { ...DEFAULT_SOFTWARE_VERSIONS_RESPONSE_MOCK, ...overrides }; +}; + +const DEFAULT_SOFTWARE_TITLE_RESPONSE = { + software_title: createMockSoftwareTitle(), +}; + +export const createMockSoftwareTitleResponse = ( + overrides?: Partial +): ISoftwareTitleResponse => { + return { ...DEFAULT_SOFTWARE_TITLE_RESPONSE, ...overrides }; +}; + +const DEFAULT_SOFTWARE_VERSION_RESPONSE = { + software: createMockSoftwareVersion(), +}; + +export const createMockSoftwareVersionResponse = ( + overrides?: Partial +): ISoftwareVersionResponse => { + return { ...DEFAULT_SOFTWARE_VERSION_RESPONSE, ...overrides }; +}; diff --git a/frontend/components/top_nav/SiteTopNav/SiteTopNav.tsx b/frontend/components/top_nav/SiteTopNav/SiteTopNav.tsx index 534253ea02..0437a3aed0 100644 --- a/frontend/components/top_nav/SiteTopNav/SiteTopNav.tsx +++ b/frontend/components/top_nav/SiteTopNav/SiteTopNav.tsx @@ -95,7 +95,6 @@ const SiteTopNav = ({ isGlobalMaintainer, isAnyTeamMaintainer, isNoAccess, - isMdmEnabledAndConfigured, // TODO: confirm isSandboxMode, } = useContext(AppContext); @@ -160,9 +159,6 @@ const SiteTopNav = ({ {name} - {/*
- {name} -
*/} ); } diff --git a/frontend/components/top_nav/SiteTopNav/navItems.ts b/frontend/components/top_nav/SiteTopNav/navItems.ts index 4b828087ed..22738ecccb 100644 --- a/frontend/components/top_nav/SiteTopNav/navItems.ts +++ b/frontend/components/top_nav/SiteTopNav/navItems.ts @@ -67,7 +67,7 @@ export default ( name: "Software", location: { regex: new RegExp(`^${URL_PREFIX}/software/`), - pathname: PATHS.MANAGE_SOFTWARE, + pathname: PATHS.SOFTWARE_TITLES, }, withParams: { type: "query", names: ["team_id"] }, }, diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index 7b285004a9..88f5c856f6 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -1,5 +1,5 @@ import PropTypes from "prop-types"; -import vulnerabilityInterface, { IVulnerability } from "./vulnerability"; +import vulnerabilityInterface from "./vulnerability"; export default PropTypes.shape({ type: PropTypes.string, @@ -23,6 +23,8 @@ export interface IGetSoftwareByIdResponse { software: ISoftware; } +// TODO: old software interface. replaced with ISoftwareVersion +// check to see if we still need this. export interface ISoftware { id: number; name: string; // e.g., "Figma.app" @@ -30,12 +32,54 @@ export interface ISoftware { bundle_identifier?: string | null; // e.g., "com.figma.Desktop" source: string; // e.g., "apps" generated_cpe: string; - vulnerabilities: IVulnerability[] | null; + vulnerabilities: ISoftwareVulnerability[] | null; hosts_count?: number; last_opened_at?: string | null; // e.g., "2021-08-18T15:11:35Z” installed_paths?: string[]; } +export interface ISoftwareTitleVersion { + id: number; + version: string; + vulnerabilities: string[] | null; // TODO: does this return null or is it omitted? + hosts_count?: number; +} + +export interface ISoftwareTitle { + id: number; + name: string; + versions_count: number; + source: string; + hosts_count: number; + versions: ISoftwareTitleVersion[]; + browser: string; +} + +export interface ISoftwareVulnerability { + cve: string; + details_link: string; + cvss_score?: number | null; + epss_probability?: number | null; + cisa_known_exploit?: boolean | null; + cve_published?: string | null; + cve_description?: string | null; + resolved_in_version?: string | null; +} + +export interface ISoftwareVersion { + id: number; + name: string; // e.g., "Figma.app" + version: string; // e.g., "2.1.11" + bundle_identifier?: string; // e.g., "com.figma.Desktop" + source: string; // e.g., "apps" + release: string; // TODO: on software/verions/:id? + vendor: string; + arch: string; // e.g., "x86_64" // TODO: on software/verions/:id? + generated_cpe: string; + vulnerabilities: ISoftwareVulnerability[] | null; + hosts_count?: number; +} + export const TYPE_CONVERSION: Record = { apt_sources: "Package (APT)", deb_packages: "Package (deb)", @@ -56,7 +100,8 @@ export const TYPE_CONVERSION: Record = { pkg_packages: "Package (pkg)", } as const; -export const formatSoftwareType = (source: string): string => { +// TODO: update with new software types +export const formatSoftwareType = (source: string) => { const DICT = TYPE_CONVERSION; return DICT[source] || "Unknown"; }; diff --git a/frontend/interfaces/vulnerability.ts b/frontend/interfaces/vulnerability.ts index 168a3dda24..3806f61326 100644 --- a/frontend/interfaces/vulnerability.ts +++ b/frontend/interfaces/vulnerability.ts @@ -4,19 +4,3 @@ export default PropTypes.shape({ cve: PropTypes.string, details_link: PropTypes.string, }); - -export interface IHostsAffected { - id: number; - display_name: string; - url: string; - software_installed_paths?: string[]; -} -export interface IVulnerability { - cve: string; - details_link: string; - cvss_score?: number; - epss_probability?: number; - cisa_known_exploit?: boolean; - cve_published?: string; - hosts_affected?: IHostsAffected[]; -} diff --git a/frontend/pages/DashboardPage/DashboardPage.tsx b/frontend/pages/DashboardPage/DashboardPage.tsx index d4e0d4b228..3a224d8b63 100644 --- a/frontend/pages/DashboardPage/DashboardPage.tsx +++ b/frontend/pages/DashboardPage/DashboardPage.tsx @@ -465,11 +465,11 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { }; const onSoftwareTabChange = (index: number) => { - const { MANAGE_SOFTWARE } = paths; + const { SOFTWARE_TITLES } = paths; setSoftwareNavTabIndex(index); setSoftwareActionUrl && setSoftwareActionUrl( - index === 1 ? `${MANAGE_SOFTWARE}?vulnerable=true` : MANAGE_SOFTWARE + index === 1 ? `${SOFTWARE_TITLES}?vulnerable=true` : SOFTWARE_TITLES ); }; diff --git a/frontend/pages/DashboardPage/cards/Software/Software.tsx b/frontend/pages/DashboardPage/cards/Software/Software.tsx index 41a9e9cd5b..734b3850d3 100644 --- a/frontend/pages/DashboardPage/cards/Software/Software.tsx +++ b/frontend/pages/DashboardPage/cards/Software/Software.tsx @@ -11,7 +11,7 @@ import TabsWrapper from "components/TabsWrapper"; import TableContainer from "components/TableContainer"; import TableDataError from "components/DataError"; import Spinner from "components/Spinner"; -import EmptySoftwareTable from "pages/software/components/EmptySoftwareTable"; +import EmptySoftwareTable from "pages/SoftwarePage/components/EmptySoftwareTable"; import generateTableHeaders from "./SoftwareTableConfig"; diff --git a/frontend/pages/SoftwarePage/SoftwarePage.tsx b/frontend/pages/SoftwarePage/SoftwarePage.tsx new file mode 100644 index 0000000000..1113a58c45 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwarePage.tsx @@ -0,0 +1,380 @@ +import React, { useCallback, useContext, useState } from "react"; +import { InjectedRouter } from "react-router"; +import { useQuery } from "react-query"; +import { Tab, TabList, Tabs } from "react-tabs"; + +import PATHS from "router/paths"; +import { + IConfig, + CONFIG_DEFAULT_RECENT_VULNERABILITY_MAX_AGE_IN_DAYS, +} from "interfaces/config"; +import { + IJiraIntegration, + IZendeskIntegration, + IIntegrations, +} from "interfaces/integration"; +import { ITeamConfig } from "interfaces/team"; +import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook"; +import configAPI from "services/entities/config"; +import teamsAPI, { ILoadTeamResponse } from "services/entities/teams"; +import { AppContext } from "context/app"; +import { NotificationContext } from "context/notification"; +import useTeamIdParam from "hooks/useTeamIdParam"; + +import Button from "components/buttons/Button"; +import MainContent from "components/MainContent"; +import TeamsDropdown from "components/TeamsDropdown"; +import TabsWrapper from "components/TabsWrapper"; + +import ManageAutomationsModal from "./components/ManageAutomationsModal"; + +interface ISoftwareSubNavItem { + name: string; + pathname: string; +} + +const softwareSubNav: ISoftwareSubNavItem[] = [ + { + name: "Software", + pathname: PATHS.SOFTWARE_TITLES, + }, + { + name: "Versions", + pathname: PATHS.SOFTWARE_VERSIONS, + }, +]; + +const getTabIndex = (path: string): number => { + return softwareSubNav.findIndex((navItem) => { + // tab stays highlighted for paths that start with same pathname + return path.startsWith(navItem.pathname); + }); +}; + +// default values for query params used on this page if not provided +const DEFAULT_SORT_DIRECTION = "desc"; +const DEFAULT_SORT_HEADER = "hosts_count"; +const DEFAULT_PAGE_SIZE = 20; +const DEFAULT_PAGE = 0; + +const baseClass = "software-page"; + +interface ISoftwareAutomations { + webhook_settings: { + vulnerabilities_webhook: IWebhookSoftwareVulnerabilities; + }; + integrations: { + jira: IJiraIntegration[]; + zendesk: IZendeskIntegration[]; + }; +} + +interface ISoftwareConfigQueryKey { + scope: string; + teamId?: number; +} + +interface ISoftwarePageProps { + children: JSX.Element; + location: { + pathname: string; + search: string; + query: { + team_id?: string; + vulnerable?: string; + page?: string; + query?: string; + order_key?: string; + order_direction?: "asc" | "desc"; + }; + hash?: string; + }; + router: InjectedRouter; // v3 +} + +const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { + const { + config: globalConfig, + isFreeTier, + isGlobalAdmin, + isGlobalMaintainer, + isOnGlobalTeam, + isPremiumTier, + isSandboxMode, + } = useContext(AppContext); + const { renderFlash } = useContext(NotificationContext); + + const queryParams = location.query; + + // initial values for query params used on this page + const query = queryParams && queryParams.query ? queryParams.query : ""; + const sortHeader = + queryParams && queryParams.order_key + ? queryParams.order_key + : DEFAULT_SORT_HEADER; + const sortDirection = + queryParams?.order_direction === undefined + ? DEFAULT_SORT_DIRECTION + : queryParams.order_direction; + const page = + queryParams && queryParams.page + ? parseInt(queryParams.page, 10) + : DEFAULT_PAGE; + const showVulnerableSoftware = + queryParams !== undefined && queryParams.vulnerable === "true"; + + const [showManageAutomationsModal, setShowManageAutomationsModal] = useState( + false + ); + const [showPreviewPayloadModal, setShowPreviewPayloadModal] = useState(false); + const [showPreviewTicketModal, setShowPreviewTicketModal] = useState(false); + + const { + currentTeamId, + isAnyTeamSelected, + isRouteOk, + teamIdForApi, + userTeams, + handleTeamChange, + } = useTeamIdParam({ + location, + router, + includeAllTeams: true, + includeNoTeam: false, + }); + + // softwareConfig is either the global config or the team config of the + // currently selected team depending on the page team context selected + // by the user. + const { + data: softwareConfig, + error: softwareConfigError, + isFetching: isFetchingSoftwareConfig, + refetch: refetchSoftwareConfig, + } = useQuery< + IConfig | ILoadTeamResponse, + Error, + IConfig | ITeamConfig, + ISoftwareConfigQueryKey[] + >( + [{ scope: "softwareConfig", teamId: teamIdForApi }], + ({ queryKey }) => { + const { teamId } = queryKey[0]; + return teamId ? teamsAPI.load(teamId) : configAPI.loadAll(); + }, + { + enabled: isRouteOk, + select: (data) => ("team" in data ? data.team : data), + } + ); + + // TODO: move into manage automations modal + const vulnWebhookSettings = + softwareConfig?.webhook_settings?.vulnerabilities_webhook; + const isVulnWebhookEnabled = !!vulnWebhookSettings?.enable_vulnerabilities_webhook; + const isVulnIntegrationEnabled = (integrations?: IIntegrations) => { + return ( + !!integrations?.jira?.some((j) => j.enable_software_vulnerabilities) || + !!integrations?.zendesk?.some((z) => z.enable_software_vulnerabilities) + ); + }; + + // TODO: move into manage automations modal + const isAnyVulnAutomationEnabled = + isVulnWebhookEnabled || + isVulnIntegrationEnabled(softwareConfig?.integrations); + + // TODO: move into manage automations modal + const recentVulnerabilityMaxAge = (() => { + let maxAgeInNanoseconds: number | undefined; + if (softwareConfig && "vulnerabilities" in softwareConfig) { + maxAgeInNanoseconds = + softwareConfig.vulnerabilities.recent_vulnerability_max_age; + } else { + maxAgeInNanoseconds = + globalConfig?.vulnerabilities.recent_vulnerability_max_age; + } + return maxAgeInNanoseconds + ? Math.round(maxAgeInNanoseconds / 86400000000000) // convert from nanoseconds to days + : CONFIG_DEFAULT_RECENT_VULNERABILITY_MAX_AGE_IN_DAYS; + })(); + + const isSoftwareConfigLoaded = + !isFetchingSoftwareConfig && !softwareConfigError && !!softwareConfig; + + const canManageAutomations = + isGlobalAdmin && (!isPremiumTier || !isAnyTeamSelected); + + const toggleManageAutomationsModal = useCallback(() => { + setShowManageAutomationsModal(!showManageAutomationsModal); + }, [setShowManageAutomationsModal, showManageAutomationsModal]); + + const togglePreviewPayloadModal = useCallback(() => { + setShowPreviewPayloadModal(!showPreviewPayloadModal); + }, [setShowPreviewPayloadModal, showPreviewPayloadModal]); + + const togglePreviewTicketModal = useCallback(() => { + setShowPreviewTicketModal(!showPreviewTicketModal); + }, [setShowPreviewTicketModal, showPreviewTicketModal]); + + // TODO: move into manage automations modal + const onCreateWebhookSubmit = async ( + configSoftwareAutomations: ISoftwareAutomations + ) => { + try { + const request = configAPI.update(configSoftwareAutomations); + await request.then(() => { + renderFlash( + "success", + "Successfully updated vulnerability automations." + ); + refetchSoftwareConfig(); + }); + } catch { + renderFlash( + "error", + "Could not update vulnerability automations. Please try again." + ); + } finally { + toggleManageAutomationsModal(); + } + }; + + const onTeamChange = useCallback( + (teamId: number) => { + handleTeamChange(teamId); + // TODO: reset page to 0 when changing teams + }, + [handleTeamChange] + ); + + const navigateToNav = useCallback( + (i: number): void => { + const navPath = softwareSubNav[i].pathname; + router.replace( + navPath.concat(location?.search || "").concat(location?.hash || "") + ); + }, + [location, router] + ); + + const renderTitle = () => { + return ( + <> + {isFreeTier &&

Software

} + {isPremiumTier && + userTeams && + (userTeams.length > 1 || isOnGlobalTeam) && ( + + )} + {isPremiumTier && + !isOnGlobalTeam && + userTeams && + userTeams.length === 1 &&

{userTeams[0].name}

} + + ); + }; + + const renderHeaderDescription = () => { + return ( +

+ Search for installed software{" "} + {(isGlobalAdmin || isGlobalMaintainer) && + (!isPremiumTier || !isAnyTeamSelected) && + "and manage automations for detected vulnerabilities (CVEs)"}{" "} + on{" "} + + {isPremiumTier && isAnyTeamSelected + ? "all hosts assigned to this team" + : "all of your hosts"} + + . +

+ ); + }; + + const renderBody = () => { + return ( +
+ + + + {softwareSubNav.map((navItem) => { + return ( + + {navItem.name} + + ); + })} + + + + {React.cloneElement(children, { + router, + isSoftwareEnabled: Boolean( + softwareConfig?.features?.enable_software_inventory + ), + query, + // NOTE: may move this lower in tree if we need different values for different pages + perPage: DEFAULT_PAGE_SIZE, + orderDirection: sortDirection, + orderKey: sortHeader, + showVulnerableSoftware, + currentPage: page, + teamId: teamIdForApi, + })} +
+ ); + }; + + return ( + +
+
+
+
+
{renderTitle()}
+
+
+ {canManageAutomations && isSoftwareConfigLoaded && ( + + )} +
+
+ {renderHeaderDescription()} +
+ {renderBody()} + {showManageAutomationsModal && ( + + )} +
+
+ ); +}; + +export default SoftwarePage; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx new file mode 100644 index 0000000000..26eaffecfa --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx @@ -0,0 +1,82 @@ +import React, { useContext } from "react"; +import { RouteComponentProps } from "react-router"; +import { useQuery } from "react-query"; + +import { AppContext } from "context/app"; +import { ISoftwareTitle } from "interfaces/software"; +import softwareAPI, { + ISoftwareTitleResponse, +} from "services/entities/software"; + +import MainContent from "components/MainContent"; +import TableDataError from "components/DataError"; + +import SoftwareDetailsSummary from "../components/SoftwareDetailsSummary"; +import SoftwareTitleDetailsTable from "./SoftwareTitleDetailsTable"; + +const baseClass = "software-title-details-page"; + +interface ISoftwareTitleDetailsRouteParams { + id: string; +} + +type ISoftwareTitleDetailsPageProps = RouteComponentProps< + undefined, + ISoftwareTitleDetailsRouteParams +>; + +const SoftwareTitleDetailsPage = ({ + router, + routeParams, +}: ISoftwareTitleDetailsPageProps) => { + // TODO: handle non integer values + const softwareId = parseInt(routeParams.id, 10); + + const { + data: softwareTitle, + isLoading: isSoftwareTitleLoading, + isError: isSoftwareTitleError, + } = useQuery( + ["softwareById", softwareId], + () => softwareAPI.getSoftwareTitle(softwareId), + { + select: (data) => data.software_title, + } + ); + + if (!softwareTitle) { + return null; + } + + return ( + + {isSoftwareTitleError ? ( + + ) : ( + <> + + {/* TODO: can we use Card here for card styles */} +
+

Versions

+ +
+ + )} +
+ ); +}; + +export default SoftwareTitleDetailsPage; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx new file mode 100644 index 0000000000..e42d6decfc --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx @@ -0,0 +1,70 @@ +import React, { useMemo } from "react"; +import { InjectedRouter } from "react-router"; + +import { ISoftwareTitleVersion } from "interfaces/software"; +import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants"; + +import TableContainer from "components/TableContainer"; +import EmptyTable from "components/EmptyTable"; +import CustomLink from "components/CustomLink"; + +import generateSoftwareTitleDetailsTableConfig from "./SoftwareTitleDetailsTableConfig"; + +const DEFAULT_SORT_HEADER = "hosts_count"; +const DEFAULT_SORT_DIRECTION = "desc"; + +const baseClass = "software-title-details-table"; + +const NoVersionsDetected = (): JSX.Element => { + return ( + + Expecting to see versions?{" "} + + + } + /> + ); +}; + +interface ISoftwareTitleDetailsTableProps { + router: InjectedRouter; + data: ISoftwareTitleVersion[]; + isLoading: boolean; +} + +const SoftwareTitleDetailsTable = ({ + router, + data, + isLoading, +}: ISoftwareTitleDetailsTableProps) => { + const softwareTableHeaders = useMemo( + () => generateSoftwareTitleDetailsTableConfig(router), + [router] + ); + + return ( + + ); +}; + +export default SoftwareTitleDetailsTable; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTableConfig.tsx new file mode 100644 index 0000000000..6e3e9c80bf --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTableConfig.tsx @@ -0,0 +1,111 @@ +import React from "react"; +import { InjectedRouter } from "react-router"; + +import { + ISoftwareTitleVersion, + ISoftwareVulnerability, +} from "interfaces/software"; +import PATHS from "router/paths"; + +import TextCell from "components/TableContainer/DataTable/TextCell"; +import ViewAllHostsLink from "components/ViewAllHostsLink"; +import LinkCell from "components/TableContainer/DataTable/LinkCell"; + +import VulnerabilitiesCell from "../../components/VulnerabilitiesCell"; + +interface ICellProps { + cell: { + value: number | string | ISoftwareVulnerability[]; + }; + row: { + original: ISoftwareTitleVersion; + }; +} + +interface IVersionCellProps extends ICellProps { + cell: { + value: string; + }; +} + +interface INumberCellProps extends ICellProps { + cell: { + value: number; + }; +} + +interface IVulnCellProps extends ICellProps { + cell: { + value: ISoftwareVulnerability[]; + }; +} + +const generateSoftwareTitleDetailsTableConfig = (router: InjectedRouter) => { + const tableHeaders = [ + { + title: "Version", + Header: "Version", + disableSortBy: true, + accessor: "version", + Cell: (cellProps: IVersionCellProps): JSX.Element => { + const { id } = cellProps.row.original; + const onClickSoftware = (e: React.MouseEvent) => { + // Allows for button to be clickable in a clickable row + e.stopPropagation(); + router?.push(PATHS.SOFTWARE_VERSION_DETAILS(id.toString())); + }; + + // TODO: make only text clickable + return ( + + ); + }, + }, + { + title: "Vulnerabilities", + Header: "Vulnerabilities", + disableSortBy: true, + // the "vulnerabilities" accessor is used but the data is actually coming + // from the version attribute. We do this as we already have a "versions" + // attribute used for the "Version" column and we cannot reuse. This is a + // limitation of react-table. + // With the versions data, we can sum up the vulnerabilities to get the + // total number of vulnerabilities for the software title + accessor: "vulnerabilities", + Cell: (cellProps: IVulnCellProps): JSX.Element => ( + + // TODO: tooltip + ), + }, + { + title: "Hosts", + Header: "Hosts", + disableSortBy: true, + accessor: "hosts_count", + Cell: (cellProps: INumberCellProps): JSX.Element => ( + + + + + + + + + ), + }, + ]; + + return tableHeaders; +}; + +export default generateSoftwareTitleDetailsTableConfig; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/_styles.scss new file mode 100644 index 0000000000..4226d6790f --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/_styles.scss @@ -0,0 +1,13 @@ +.software-title-details-table { + + .hosts-cell__wrapper { + display: flex; + align-items: center; + justify-content: space-between; + + .hosts-cell__link { + display: flex; + white-space: nowrap; + } + } +} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/index.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/index.ts new file mode 100644 index 0000000000..2e2c71c6e0 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwareTitleDetailsTable"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/_styles.scss new file mode 100644 index 0000000000..4a0f501b35 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/_styles.scss @@ -0,0 +1,33 @@ +.software-title-details-page { + background-color: $ui-off-white; + display: flex; + flex-direction: column; + gap: $pad-medium; + + &__versions-section { + background-color: $core-white; + padding: $pad-xxlarge; + border: 1px solid $ui-fleet-black-10; + border-radius: $border-radius-xxlarge; + box-shadow: $box-shadow; + + h2 { + margin: 0; + font-size: $medium; + } + } + + // for showing and hiding software link on hover + tr { + .software-link { + opacity: 0; + transition: opacity 250ms; + } + + &:hover { + .software-link { + opacity: 1; + } + } + } +} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/index.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/index.ts new file mode 100644 index 0000000000..a071356328 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwareTitleDetailsPage"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx new file mode 100644 index 0000000000..df84248aa7 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx @@ -0,0 +1,303 @@ +import React, { useCallback, useContext, useMemo } from "react"; +import { InjectedRouter } from "react-router"; +import { useQuery } from "react-query"; +import { Row } from "react-table"; + +import PATHS from "router/paths"; +import softwareAPI, { + ISoftwareApiParams, + ISoftwareTitlesResponse, +} from "services/entities/software"; +import { AppContext } from "context/app"; +import { + GITHUB_NEW_ISSUE_LINK, + VULNERABLE_DROPDOWN_OPTIONS, +} from "utilities/constants"; +import { getNextLocationPath } from "utilities/helpers"; +import { buildQueryStringFromParams } from "utilities/url"; + +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; +import TableDataError from "components/DataError"; +import TableContainer from "components/TableContainer"; +import CustomLink from "components/CustomLink"; +import LastUpdatedText from "components/LastUpdatedText"; +import { ITableQueryData } from "components/TableContainer/TableContainer"; + +import EmptySoftwareTable from "../components/EmptySoftwareTable"; + +import generateSoftwareTitlesTableHeaders from "./SoftwareTitlesTableConfig"; + +const baseClass = "software-titles"; + +interface IRowProps extends Row { + original: { + id?: number; + }; +} + +interface ISoftwareTitlesQueryKey extends ISoftwareApiParams { + scope: "software-titles"; +} + +interface ISoftwareTitlesProps { + router: InjectedRouter; + isSoftwareEnabled: boolean; + query: string; + perPage: number; + orderDirection: "asc" | "desc"; + orderKey: string; + showVulnerableSoftware: boolean; + currentPage: number; + teamId?: number; +} + +const SoftwareTitles = ({ + router, + isSoftwareEnabled, + query, + perPage, + orderDirection, + orderKey, + showVulnerableSoftware, + currentPage, + teamId, +}: ISoftwareTitlesProps) => { + const { isSandboxMode, noSandboxHosts } = useContext(AppContext); + + // request to get software data + const { + data: softwareData, + isLoading: isSoftwareLoading, + isError: isSoftwareError, + } = useQuery< + ISoftwareTitlesResponse, + Error, + ISoftwareTitlesResponse, + ISoftwareTitlesQueryKey[] + >( + [ + { + scope: "software-titles", + page: currentPage, + perPage, + query, + orderDirection, + orderKey, + teamId, + vulnerable: showVulnerableSoftware, + }, + ], + ({ queryKey }) => softwareAPI.getSoftwareTitles(queryKey[0]), + { + // stale time can be adjusted if fresher data is desired based on + // software inventory interval + staleTime: 30000, + } + ); + + // determines if a user be able to search in the table + const searchable = + isSoftwareEnabled && + (!!softwareData?.software_titles || query !== "" || showVulnerableSoftware); + + const softwareTableHeaders = useMemo( + () => generateSoftwareTitlesTableHeaders(router, teamId), + [router, teamId] + ); + + const handleVulnFilterDropdownChange = (isFilterVulnerable: string) => { + router.replace( + getNextLocationPath({ + pathPrefix: PATHS.SOFTWARE_TITLES, + routeTemplate: "", + queryParams: { + query, + teamId, + orderDirection, + orderKey, + vulnerable: isFilterVulnerable, + page: 0, // resets page index + }, + }) + ); + }; + + const handleRowSelect = (row: IRowProps) => { + const hostsBySoftwareParams = { + software_title_id: row.original.id, + team_id: teamId, + }; + + const path = `${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams( + hostsBySoftwareParams + )}`; + + router.push(path); + }; + + 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 "vulnerable": + return val !== showVulnerableSoftware.toString(); + case "pageIndex": + return val !== currentPage; + default: + return false; + } + }); + return changedEntry?.[0] ?? ""; + }, + [currentPage, orderDirection, orderKey, query, showVulnerableSoftware] + ); + + const generateNewQueryParams = useCallback( + (newTableQuery: ITableQueryData, changedParam: string) => { + return { + query: newTableQuery.searchQuery, + team_id: teamId, + order_direction: newTableQuery.sortDirection, + order_key: newTableQuery.sortHeader, + vulnerable: showVulnerableSoftware.toString(), + page: changedParam === "pageIndex" ? newTableQuery.pageIndex : 0, + }; + }, + [showVulnerableSoftware, teamId] + ); + + // 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. + if (changedParam === "") return; + + const newRoute = getNextLocationPath({ + pathPrefix: PATHS.SOFTWARE_TITLES, + routeTemplate: "", + queryParams: generateNewQueryParams(newTableQuery, changedParam), + }); + + router.replace(newRoute); + }, + [determineQueryParamChange, generateNewQueryParams, router] + ); + + const getItemsCountText = () => { + const count = softwareData?.count; + if (!softwareData || !count) return ""; + + return count === 1 ? `${count} item` : `${count} items`; + }; + + const getLastUpdatedText = () => { + if (!softwareData || !softwareData.counts_updated_at) return ""; + return ( + + ); + }; + + const renderSoftwareCount = () => { + const itemText = getItemsCountText(); + const lastUpdatedText = getLastUpdatedText(); + + if (!itemText) return null; + + return ( +
+ {itemText} + {lastUpdatedText} +
+ ); + }; + + const renderVulnFilterDropdown = () => { + return ( + + ); + }; + + const renderTableFooter = () => { + return ( +
+ Seeing unexpected software or vulnerabilities?{" "} + +
+ ); + }; + + if (isSoftwareError) { + return ; + } + + return ( +
+ ( + + )} + defaultSortHeader={orderKey} + defaultSortDirection={orderDirection} + defaultPageIndex={currentPage} + defaultSearchQuery={query} + manualSortBy + pageSize={perPage} + showMarkAllPages={false} + isAllPagesSelected={false} + disableNextPage={!softwareData?.meta.has_next_results} + searchable={searchable} + inputPlaceHolder="Search by name or vulnerabilities (CVEs)" + onQueryChange={onQueryChange} + // additionalQueries serves as a trigger for the useDeepEffect hook + // to fire onQueryChange for events happeing outside of + // the TableContainer. + additionalQueries={showVulnerableSoftware ? "vulnerable" : ""} + customControl={searchable ? renderVulnFilterDropdown : undefined} + stackControls + renderCount={renderSoftwareCount} + renderFooter={renderTableFooter} + disableMultiRowSelect + onSelectSingleRow={handleRowSelect} + /> +
+ ); +}; + +export default SoftwareTitles; diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitlesTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitlesTableConfig.tsx new file mode 100644 index 0000000000..3e48b76a55 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitlesTableConfig.tsx @@ -0,0 +1,185 @@ +import React from "react"; +import { Column } from "react-table"; +import { InjectedRouter } from "react-router"; + +import { + ISoftwareTitleVersion, + ISoftwareTitle, + formatSoftwareType, +} from "interfaces/software"; +import PATHS from "router/paths"; + +import HeaderCell from "components/TableContainer/DataTable/HeaderCell"; +import TextCell from "components/TableContainer/DataTable/TextCell"; +import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell"; +import ViewAllHostsLink from "components/ViewAllHostsLink"; +import VersionCell from "../components/VersionCell"; +import VulnerabilitiesCell from "../components/VulnerabilitiesCell"; +import SoftwareIcon from "../components/icons/SoftwareIcon"; + +// NOTE: cellProps come from react-table +// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties +interface ICellProps { + cell: { + value: number | string | ISoftwareTitleVersion[]; + }; + row: { + original: ISoftwareTitle; + }; +} +interface IStringCellProps extends ICellProps { + cell: { + value: string; + }; +} + +interface IVersionCellProps extends ICellProps { + cell: { + value: ISoftwareTitleVersion[]; + }; + row: { + original: ISoftwareTitle; + }; +} + +interface INumberCellProps extends ICellProps { + cell: { + value: number; + }; +} + +interface IVulnCellProps extends ICellProps { + cell: { + value: ISoftwareTitleVersion[]; + }; +} +interface IHeaderProps { + column: { + title: string; + isSortedDesc: boolean; + }; +} + +const getVulnerabilities = (versions: ISoftwareTitleVersion[]) => { + const vulnerabilities = versions.reduce((acc: string[], currentVersion) => { + if ( + currentVersion.vulnerabilities && + currentVersion.vulnerabilities.length !== 0 + ) { + acc.push(...currentVersion.vulnerabilities); + } + return acc; + }, []); + return vulnerabilities; +}; + +const generateTableHeaders = ( + router: InjectedRouter, + teamId?: number +): Column[] => { + const softwareTableHeaders = [ + { + title: "Name", + Header: (cellProps: IHeaderProps): JSX.Element => ( + + ), + disableSortBy: false, + accessor: "name", + Cell: (cellProps: IStringCellProps): JSX.Element => { + const { id, name, source } = cellProps.row.original; + + const onClickSoftware = (e: React.MouseEvent) => { + // Allows for button to be clickable in a clickable row + e.stopPropagation(); + + router?.push(PATHS.SOFTWARE_TITLE_DETAILS(id.toString())); + }; + + return ( + + + {name} + + } + /> + ); + }, + sortType: "caseInsensitive", + }, + { + title: "Version", + Header: "Version", + disableSortBy: true, + accessor: "versions", + Cell: (cellProps: IVersionCellProps): JSX.Element => ( + + ), + }, + { + title: "Type", + Header: "Type", + disableSortBy: true, + accessor: "source", + Cell: (cellProps: IStringCellProps): JSX.Element => ( + + ), + }, + // the "vulnerabilities" accessor is used but the data is actually coming + // from the version attribute. We do this as we already have a "versions" + // attribute used for the "Version" column and we cannot reuse. This is a + // limitation of react-table. + // With the versions data, we can sum up the vulnerabilities to get the + // total number of vulnerabilities for the software title + { + title: "Vulnerabilities", + Header: "Vulnerabilities", + disableSortBy: true, + accessor: "vulnerabilities", + Cell: (cellProps: IVulnCellProps): JSX.Element => { + const vulnerabilities = getVulnerabilities( + cellProps.row.original.versions + ); + return ; + }, + }, + { + title: "Hosts", + Header: (cellProps: IHeaderProps): JSX.Element => ( + + ), + disableSortBy: false, + accessor: "hosts_count", + Cell: (cellProps: INumberCellProps): JSX.Element => ( + + + + + + + + + ), + }, + ]; + + return softwareTableHeaders; +}; + +export default generateTableHeaders; diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitles/_styles.scss new file mode 100644 index 0000000000..ee2569f458 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitles/_styles.scss @@ -0,0 +1,165 @@ +.software-titles { + margin-top: $pad-xxlarge; + + &__count { + display: flex; + gap: 12px; + } + + &__vuln_dropdown { + .Select-menu-outer { + width: 250px; + max-height: 310px; + + .Select-menu { + max-height: none; + } + } + + .Select-value { + padding-left: $pad-medium; + padding-right: $pad-medium; + } + + .dropdown__custom-value-label { + width: 155px; // Override 105px for longer text options + } + } + + .table-container { + &__header { + flex-direction: column-reverse; // Search bar on top + margin-bottom: $pad-medium; + + @media (min-width: $break-md) { + flex-direction: row; + } + } + + &__header-left { + flex-direction: row; // Filter dropdown aligned with count + + .controls { + .form-field--dropdown { + margin: 0; + } + } + } + + &__search-input, + &__search { + width: 100%; // Search bar across entire table + + .input-icon-field__input { + width: 100%; + } + + @media (min-width: $break-md) { + width: auto; + + .input-icon-field__input { + width: 375px; + } + } + } + + &__data-table-block { + .data-table-block { + .data-table__table { + + // for showing and hiding "view all hosts" link on hover + tr { + .software-link { + opacity: 0; + transition: opacity 250ms; + } + + &:hover { + .software-link { + opacity: 1; + } + } + } + + thead { + .name__header { + width: $col-md; + } + + .hosts_count__header { + width: auto; + border-right: 0; + } + + @media (min-width: $break-lg) { + // expand the width of version header at larger screen sizes + .versions__header { + width: $col-md; + } + } + } + + tbody { + .name__cell { + max-width: $col-md; + + // Tooltip does not get cut off + .children-wrapper { + overflow: initial; + } + + // ellipsis for software name + .software-name { + overflow: hidden; + text-wrap: nowrap; + text-overflow: ellipsis; + } + } + + .link-cell { + display: flex; + align-items: center; + gap: $pad-small; + } + + .hosts_count__cell { + .hosts-cell__wrapper { + display: flex; + align-items: center; + justify-content: space-between; + + .hosts-cell__link { + display: flex; + white-space: nowrap; + } + } + } + + @media (min-width: $break-sm) { + .name__cell { + max-width: $col-lg; + } + } + + @media (min-width: $break-lg) { + .versions__cell { + width: $col-md; + } + } + } + } + } + } + } + + // needed to handle overflow of the table data on small screens + .data-table { + &__wrapper { + overflow-x: auto; + } + } + + &__table-error { + margin-top: $pad-xxxlarge; + } +} diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/index.ts b/frontend/pages/SoftwarePage/SoftwareTitles/index.ts new file mode 100644 index 0000000000..36fd17a897 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitles/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwareTitles"; diff --git a/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx new file mode 100644 index 0000000000..e8860641e3 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx @@ -0,0 +1,125 @@ +import React, { useContext, useMemo } from "react"; +import { useQuery } from "react-query"; +import { RouteComponentProps } from "react-router"; + +import softwareAPI, { + ISoftwareVersionResponse, +} from "services/entities/software"; +import { ISoftwareVersion } from "interfaces/software"; +import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants"; +import { AppContext } from "context/app"; + +import MainContent from "components/MainContent"; +import TableContainer from "components/TableContainer"; +import CustomLink from "components/CustomLink"; +import EmptyTable from "components/EmptyTable"; +import TableDataError from "components/DataError"; + +import generateSoftwareVersionDetailsTableConfig from "./SoftwareVersionDetailsTableConfig"; +import SoftwareDetailsSummary from "../components/SoftwareDetailsSummary"; + +const baseClass = "software-version-details-page"; + +interface ISoftwareVersionDetailsRouteParams { + id: string; +} + +type ISoftwareTitleDetailsPageProps = RouteComponentProps< + undefined, + ISoftwareVersionDetailsRouteParams +>; + +const NoVulnsDetected = (): JSX.Element => { + return ( + + Expecting to see vulnerabilities?{" "} + + + } + /> + ); +}; + +const SoftwareVersionDetailsPage = ({ + routeParams, +}: ISoftwareTitleDetailsPageProps) => { + const versionId = parseInt(routeParams.id, 10); + const { isPremiumTier, isSandboxMode, filteredSoftwarePath } = useContext( + AppContext + ); + + const { + data: softwareVersion, + isLoading: isSoftwareVersionLoading, + isError: isSoftwareVersionError, + } = useQuery( + ["software-version", versionId], + () => softwareAPI.getSoftwareVersion(versionId), + { + select: (data) => data.software, + } + ); + + const tableHeaders = useMemo( + () => + generateSoftwareVersionDetailsTableConfig( + Boolean(isPremiumTier), + Boolean(isSandboxMode) + ), + [isPremiumTier, isSandboxMode] + ); + + if (!softwareVersion) { + return null; + } + + return ( + + {isSoftwareVersionError ? ( + + ) : ( + <> + +
+

Vulnerabilities

+ {softwareVersion?.vulnerabilities?.length ? ( +
+ +
+ ) : ( + + )} +
+ + )} +
+ ); +}; +export default SoftwareVersionDetailsPage; diff --git a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/VulnTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsTableConfig.tsx similarity index 96% rename from frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/VulnTableConfig.tsx rename to frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsTableConfig.tsx index 518b8f58b5..1d3b036694 100644 --- a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/VulnTableConfig.tsx +++ b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsTableConfig.tsx @@ -1,6 +1,5 @@ import React from "react"; -import { IVulnerability } from "interfaces/vulnerability"; import { formatFloatAsPercentage } from "utilities/helpers"; import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; @@ -10,6 +9,7 @@ import TooltipWrapper from "components/TooltipWrapper"; import CustomLink from "components/CustomLink"; import { HumanTimeDiffWithDateTip } from "components/HumanTimeDiffWithDateTip"; import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip"; +import { ISoftwareVulnerability } from "interfaces/software"; interface IHeaderProps { column: { @@ -22,7 +22,7 @@ interface ICellProps { value: number | string | string[]; }; row: { - original: IVulnerability; + original: ISoftwareVulnerability; index: number; }; } @@ -62,7 +62,7 @@ const formatSeverity = (float: number | null) => { return `${severity} (${float.toFixed(1)})`; }; -const generateVulnTableHeaders = ( +const generateSoftwareVersionDetailsTableConfig = ( isPremiumTier: boolean, isSandboxMode: boolean ): IDataColumn[] => { @@ -106,11 +106,11 @@ const generateVulnTableHeaders = ( ); return ( <> - {isSandboxMode && } + {isSandboxMode && } ); }, @@ -140,11 +140,11 @@ const generateVulnTableHeaders = ( ); return ( <> - {isSandboxMode && } + {isSandboxMode && } ); }, @@ -173,11 +173,11 @@ const generateVulnTableHeaders = ( ); return ( <> - {isSandboxMode && } + {isSandboxMode && } ); }, @@ -205,11 +205,11 @@ const generateVulnTableHeaders = ( ); return ( <> - {isSandboxMode && } + {isSandboxMode && } ); }, @@ -228,4 +228,4 @@ const generateVulnTableHeaders = ( return isPremiumTier ? tableHeaders.concat(premiumHeaders) : tableHeaders; }; -export default generateVulnTableHeaders; +export default generateSoftwareVersionDetailsTableConfig; diff --git a/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/_styles.scss b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/_styles.scss new file mode 100644 index 0000000000..55c572cad2 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/_styles.scss @@ -0,0 +1,25 @@ +.software-version-details-page { + background-color: $ui-off-white; + display: flex; + flex-direction: column; + gap: $pad-medium; + + &__vulnerabilities-section { + background-color: $core-white; + padding: $pad-xxlarge; + border: 1px solid $ui-fleet-black-10; + border-radius: $border-radius-xxlarge; + box-shadow: $box-shadow; + + h2 { + margin: 0; + font-size: $medium; + } + } + + // used to position header text with premium icon correctly + .column-header { + display: flex; + gap: $pad-small; + } +} diff --git a/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/index.ts b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/index.ts new file mode 100644 index 0000000000..f2db109234 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwareVersionDetailsPage"; diff --git a/frontend/pages/SoftwarePage/SoftwareVersions/SoftwareVersions.tsx b/frontend/pages/SoftwarePage/SoftwareVersions/SoftwareVersions.tsx new file mode 100644 index 0000000000..1d1aeb3e86 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareVersions/SoftwareVersions.tsx @@ -0,0 +1,318 @@ +import React, { useCallback, useContext, useMemo } from "react"; +import { InjectedRouter } from "react-router"; +import { useQuery } from "react-query"; +import { Row } from "react-table"; + +import PATHS from "router/paths"; +import softwareAPI, { + ISoftwareApiParams, + ISoftwareVersionsResponse, +} from "services/entities/software"; +import { AppContext } from "context/app"; +import { + GITHUB_NEW_ISSUE_LINK, + VULNERABLE_DROPDOWN_OPTIONS, +} from "utilities/constants"; +import { getNextLocationPath } from "utilities/helpers"; +import { buildQueryStringFromParams } from "utilities/url"; + +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; +import TableDataError from "components/DataError"; +import TableContainer from "components/TableContainer"; +import CustomLink from "components/CustomLink"; +import LastUpdatedText from "components/LastUpdatedText"; +import { ITableQueryData } from "components/TableContainer/TableContainer"; + +import EmptySoftwareTable from "../components/EmptySoftwareTable"; + +import generateSoftwareVersionsTableHeaders from "./SoftwareVersionsTableConfig"; + +const baseClass = "software-versions"; + +interface IRowProps extends Row { + original: { + id?: number; + }; +} + +interface ISoftwareVersionsQueryKey extends ISoftwareApiParams { + scope: "software-versions"; +} + +interface ISoftwareVersionsProps { + router: InjectedRouter; + isSoftwareEnabled: boolean; + query: string; + perPage: number; + orderDirection: "asc" | "desc"; + orderKey: string; + showVulnerableSoftware: boolean; + currentPage: number; + teamId?: number; +} + +const SoftwareVersions = ({ + router, + isSoftwareEnabled, + query, + perPage, + orderDirection, + orderKey, + showVulnerableSoftware, + currentPage, + teamId, +}: ISoftwareVersionsProps) => { + const { isSandboxMode, noSandboxHosts, isPremiumTier } = useContext( + AppContext + ); + + // request to get software versions data + const { + data: softwareVersionsData, + isLoading: isSoftwareVersionsLoading, + isError: isSoftwareVersionsError, + } = useQuery< + ISoftwareVersionsResponse, + Error, + ISoftwareVersionsResponse, + ISoftwareVersionsQueryKey[] + >( + [ + { + scope: "software-versions", + page: currentPage, + perPage, + query, + orderDirection, + orderKey, + teamId, + vulnerable: showVulnerableSoftware, + }, + ], + ({ queryKey }) => softwareAPI.getSoftwareVersions(queryKey[0]), + { + keepPreviousData: true, + // stale time can be adjusted if fresher data is desired based on + // software inventory interval + staleTime: 30000, + } + ); + + // determines if a user be able to search in the table + const searchable = + isSoftwareEnabled && + (!!softwareVersionsData?.software || + query !== "" || + showVulnerableSoftware); + + const softwareTableHeaders = useMemo( + () => + generateSoftwareVersionsTableHeaders( + router, + isPremiumTier, + isSandboxMode, + teamId + ), + [isPremiumTier, isSandboxMode, router, teamId] + ); + + // TODO: figure out why this is not working + const handleVulnFilterDropdownChange = (isFilterVulnerable: string) => { + router.replace( + getNextLocationPath({ + pathPrefix: PATHS.SOFTWARE_VERSIONS, + routeTemplate: "", + queryParams: { + query, + teamId, + orderDirection, + orderKey, + vulnerable: isFilterVulnerable, + page: 0, // resets page index + }, + }) + ); + }; + + const handleRowSelect = (row: IRowProps) => { + const hostsBySoftwareParams = { + software_version_id: row.original.id, + team_id: teamId, + }; + + const path = `${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams( + hostsBySoftwareParams + )}`; + + router.push(path); + }; + + 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 "vulnerable": + return val !== showVulnerableSoftware.toString(); + case "pageIndex": + return val !== currentPage; + default: + return false; + } + }); + return changedEntry?.[0] ?? ""; + }, + [currentPage, orderDirection, orderKey, query, showVulnerableSoftware] + ); + + const generateNewQueryParams = useCallback( + (newTableQuery: ITableQueryData) => { + return { + query: newTableQuery.searchQuery, + team_id: teamId, + order_direction: newTableQuery.sortDirection, + order_key: newTableQuery.sortHeader, + vulnerable: showVulnerableSoftware.toString(), + page: newTableQuery.pageIndex, + }; + }, + [showVulnerableSoftware, teamId] + ); + + // 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. + if (changedParam === "") return; + + const newRoute = getNextLocationPath({ + pathPrefix: PATHS.SOFTWARE_VERSIONS, + routeTemplate: "", + queryParams: generateNewQueryParams(newTableQuery), + }); + + router.replace(newRoute); + }, + [determineQueryParamChange, generateNewQueryParams, router] + ); + + const getItemsCountText = () => { + const count = softwareVersionsData?.count; + if (!softwareVersionsData || !count) return ""; + + return count === 1 ? `${count} item` : `${count} items`; + }; + + const getLastUpdatedText = () => { + if (!softwareVersionsData || !softwareVersionsData.counts_updated_at) + return ""; + return ( + + ); + }; + + const renderSoftwareCount = () => { + const itemText = getItemsCountText(); + const lastUpdatedText = getLastUpdatedText(); + + if (!itemText) return null; + + return ( +
+ {itemText} + {lastUpdatedText} +
+ ); + }; + + const renderVulnFilterDropdown = () => { + return ( + + ); + }; + + const renderTableFooter = () => { + return ( +
+ Seeing unexpected software or vulnerabilities?{" "} + +
+ ); + }; + + if (isSoftwareVersionsError) { + return ; + } + + return ( +
+
+ ( + + )} + defaultSortHeader={orderKey} + defaultSortDirection={orderDirection} + defaultPageIndex={currentPage} + defaultSearchQuery={query} + manualSortBy + pageSize={perPage} + showMarkAllPages={false} + isAllPagesSelected={false} + disableNextPage={!softwareVersionsData?.meta.has_next_results} + searchable={searchable} + inputPlaceHolder="Search by name or vulnerabilities (CVEs)" + onQueryChange={onQueryChange} + // additionalQueries serves as a trigger for the useDeepEffect hook + // to fire onQueryChange for events happeing outside of + // the TableContainer. + additionalQueries={showVulnerableSoftware ? "vulnerable" : ""} + customControl={searchable ? renderVulnFilterDropdown : undefined} + stackControls + renderCount={renderSoftwareCount} + renderFooter={renderTableFooter} + disableMultiRowSelect + onSelectSingleRow={handleRowSelect} + /> +
+
+ ); +}; + +export default SoftwareVersions; diff --git a/frontend/pages/SoftwarePage/SoftwareVersions/SoftwareVersionsTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareVersions/SoftwareVersionsTableConfig.tsx new file mode 100644 index 0000000000..28ef2b9bf0 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareVersions/SoftwareVersionsTableConfig.tsx @@ -0,0 +1,160 @@ +import React from "react"; +import { Column } from "react-table"; +import { InjectedRouter } from "react-router"; + +import { + formatSoftwareType, + ISoftwareVersion, + ISoftwareVulnerability, +} from "interfaces/software"; +import PATHS from "router/paths"; + +import HeaderCell from "components/TableContainer/DataTable/HeaderCell"; +import TextCell from "components/TableContainer/DataTable/TextCell"; +import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell"; +import ViewAllHostsLink from "components/ViewAllHostsLink"; +import VulnerabilitiesCell from "../components/VulnerabilitiesCell"; +import SoftwareIcon from "../components/icons/SoftwareIcon"; + +// NOTE: cellProps come from react-table +// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties +interface ICellProps { + cell: { + value: number | string | ISoftwareVulnerability[]; + }; + row: { + original: ISoftwareVersion; + }; +} +interface IStringCellProps extends ICellProps { + cell: { + value: string; + }; +} + +interface IVersionCellProps extends ICellProps { + cell: { + value: string; + }; +} + +interface INumberCellProps extends ICellProps { + cell: { + value: number; + }; +} + +interface IVulnCellProps extends ICellProps { + cell: { + value: ISoftwareVulnerability[]; + }; +} +interface IHeaderProps { + column: { + title: string; + isSortedDesc: boolean; + }; +} + +const generateTableHeaders = ( + router: InjectedRouter, + isPremiumTier?: boolean, + isSandboxMode?: boolean, + teamId?: number +): Column[] => { + const softwareTableHeaders = [ + { + title: "Name", + Header: (cellProps: IHeaderProps): JSX.Element => ( + + ), + disableSortBy: false, + accessor: "name", + Cell: (cellProps: IStringCellProps): JSX.Element => { + const { id, name, source } = cellProps.row.original; + + const onClickSoftware = (e: React.MouseEvent) => { + // Allows for button to be clickable in a clickable row + e.stopPropagation(); + router?.push(PATHS.SOFTWARE_VERSION_DETAILS(id.toString())); + }; + + return ( + + + {name} + + } + /> + ); + }, + sortType: "caseInsensitive", + }, + { + title: "Version", + Header: "Version", + disableSortBy: true, + accessor: "version", + Cell: (cellProps: IVersionCellProps): JSX.Element => ( + + ), + }, + { + title: "Type", + Header: "Type", + disableSortBy: true, + accessor: "source", + Cell: (cellProps: IStringCellProps): JSX.Element => ( + + ), + }, + { + title: "Vulnerabilities", + Header: "Vulnerabilities", + disableSortBy: true, + accessor: "vulnerabilities", + Cell: (cellProps: IVulnCellProps): JSX.Element => ( + + ), + }, + { + title: "Hosts", + Header: (cellProps: IHeaderProps): JSX.Element => ( + + ), + disableSortBy: false, + accessor: "hosts_count", + Cell: (cellProps: INumberCellProps): JSX.Element => ( + + + + + + + + + ), + }, + ]; + + return softwareTableHeaders; +}; + +export default generateTableHeaders; diff --git a/frontend/pages/SoftwarePage/SoftwareVersions/_styles.scss b/frontend/pages/SoftwarePage/SoftwareVersions/_styles.scss new file mode 100644 index 0000000000..f7087cfcca --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareVersions/_styles.scss @@ -0,0 +1,166 @@ +.software-versions { + margin-top: $pad-xxlarge; + + &__count { + display: flex; + gap: 12px; + } + + &__vuln_dropdown { + .Select-menu-outer { + width: 250px; + max-height: 310px; + + .Select-menu { + max-height: none; + } + } + + .Select-value { + padding-left: $pad-medium; + padding-right: $pad-medium; + } + + .dropdown__custom-value-label { + width: 155px; // Override 105px for longer text options + } + } + + .table-container { + &__header { + flex-direction: column-reverse; // Search bar on top + margin-bottom: $pad-medium; + + @media (min-width: $break-md) { + flex-direction: row; + } + } + + &__header-left { + flex-direction: row; // Filter dropdown aligned with count + + .controls { + .form-field--dropdown { + margin: 0; + } + } + } + + &__search-input, + &__search { + width: 100%; // Search bar across entire table + + .input-icon-field__input { + width: 100%; + } + + @media (min-width: $break-md) { + width: auto; + + .input-icon-field__input { + width: 375px; + } + } + } + + &__data-table-block { + .data-table-block { + .data-table__table { + + // for showing and hiding "view all hosts" link on hover + tr { + .software-link { + opacity: 0; + transition: opacity 250ms; + } + + &:hover { + .software-link { + opacity: 1; + } + } + } + + thead { + .name__header { + width: $col-md; + } + + .hosts_count__header { + width: auto; + border-right: 0; + } + + @media (min-width: $break-lg) { + // expand the width of version header at larger screen sizes + .version__header { + width: $col-md; + } + } + } + + tbody { + .name__cell { + max-width: $col-md; + + // Tooltip does not get cut off + .children-wrapper { + overflow: initial; + } + + // ellipsis for software name + .software-name { + overflow: hidden; + text-wrap: nowrap; + text-overflow: ellipsis; + } + } + + .link-cell { + display: flex; + align-items: center; + gap: $pad-small; + } + + + .hosts_count__cell { + .hosts-cell__wrapper { + display: flex; + align-items: center; + justify-content: space-between; + + .hosts-cell__link { + display: flex; + white-space: nowrap; + } + } + } + + @media (min-width: $break-sm) { + .name__cell { + max-width: $col-lg; + } + } + + @media (min-width: $break-lg) { + .version__cell { + width: $col-md; + } + } + } + } + } + } + } + + // needed to handle overflow of the table data on small screens + .data-table { + &__wrapper { + overflow-x: auto; + } + } + + &__table-error { + margin-top: $pad-xxxlarge; + } +} diff --git a/frontend/pages/SoftwarePage/SoftwareVersions/index.ts b/frontend/pages/SoftwarePage/SoftwareVersions/index.ts new file mode 100644 index 0000000000..cdd1fa3a9c --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareVersions/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwareVersions"; diff --git a/frontend/pages/SoftwarePage/_styles.scss b/frontend/pages/SoftwarePage/_styles.scss new file mode 100644 index 0000000000..0f9fe85494 --- /dev/null +++ b/frontend/pages/SoftwarePage/_styles.scss @@ -0,0 +1,59 @@ +.software-page { + &__header-wrap { + display: flex; + align-items: center; + justify-content: space-between; + height: 38px; + + .button-wrap { + display: flex; + justify-content: flex-end; + min-width: 266px; + } + } + + &__manage-automations { + padding: $pad-small $pad-medium; + } + + &__header { + display: flex; + align-items: center; + + .form-field { + margin-bottom: 0; + } + } + + &__text { + margin-right: $pad-large; + } + + &__title { + font-size: $large; + } + + &__description { + margin: 0; + margin-bottom: $pad-large; + max-width: 75%; + + @media (min-width: $break-md) { + max-width: none; + } + + h2 { + text-transform: uppercase; + color: $core-fleet-black; + font-weight: $regular; + font-size: $small; + } + + p { + color: $ui-fleet-black-75; + margin: 0; + font-size: $x-small; + font-style: italic; + } + } +} diff --git a/frontend/pages/software/components/EmptySoftwareTable.tsx b/frontend/pages/SoftwarePage/components/EmptySoftwareTable/EmptySoftwareTable.tsx similarity index 94% rename from frontend/pages/software/components/EmptySoftwareTable.tsx rename to frontend/pages/SoftwarePage/components/EmptySoftwareTable/EmptySoftwareTable.tsx index 8caf3b8bc2..1596ea750b 100644 --- a/frontend/pages/software/components/EmptySoftwareTable.tsx +++ b/frontend/pages/SoftwarePage/components/EmptySoftwareTable/EmptySoftwareTable.tsx @@ -1,4 +1,5 @@ -// This component is used on DashboardPage.tsx > Software.tsx, Host Details/Device User > Software.tsx, and ManageSoftwarePage.tsx +// This component is used on DashboardPage.tsx > Software.tsx, +// Host Details / Device User > Software.tsx, and SoftwarePage.tsx import React from "react"; diff --git a/frontend/pages/SoftwarePage/components/EmptySoftwareTable/index.ts b/frontend/pages/SoftwarePage/components/EmptySoftwareTable/index.ts new file mode 100644 index 0000000000..17fa4e965d --- /dev/null +++ b/frontend/pages/SoftwarePage/components/EmptySoftwareTable/index.ts @@ -0,0 +1 @@ +export { default } from "./EmptySoftwareTable"; diff --git a/frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/ManageAutomationsModal.tsx b/frontend/pages/SoftwarePage/components/ManageAutomationsModal/ManageAutomationsModal.tsx similarity index 100% rename from frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/ManageAutomationsModal.tsx rename to frontend/pages/SoftwarePage/components/ManageAutomationsModal/ManageAutomationsModal.tsx diff --git a/frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/_styles.scss b/frontend/pages/SoftwarePage/components/ManageAutomationsModal/_styles.scss similarity index 100% rename from frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/_styles.scss rename to frontend/pages/SoftwarePage/components/ManageAutomationsModal/_styles.scss diff --git a/frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/index.ts b/frontend/pages/SoftwarePage/components/ManageAutomationsModal/index.ts similarity index 100% rename from frontend/pages/software/ManageSoftwarePage/components/ManageAutomationsModal/index.ts rename to frontend/pages/SoftwarePage/components/ManageAutomationsModal/index.ts diff --git a/frontend/pages/software/ManageSoftwarePage/components/PreviewPayloadModal/PreviewPayloadModal.tsx b/frontend/pages/SoftwarePage/components/PreviewPayloadModal/PreviewPayloadModal.tsx similarity index 88% rename from frontend/pages/software/ManageSoftwarePage/components/PreviewPayloadModal/PreviewPayloadModal.tsx rename to frontend/pages/SoftwarePage/components/PreviewPayloadModal/PreviewPayloadModal.tsx index 30b4a5fb08..eeb5a96537 100644 --- a/frontend/pages/software/ManageSoftwarePage/components/PreviewPayloadModal/PreviewPayloadModal.tsx +++ b/frontend/pages/SoftwarePage/components/PreviewPayloadModal/PreviewPayloadModal.tsx @@ -1,8 +1,9 @@ import React, { useContext } from "react"; -import { syntaxHighlight } from "utilities/helpers"; import { AppContext } from "context/app"; -import { IVulnerability } from "interfaces/vulnerability"; +import { syntaxHighlight } from "utilities/helpers"; +import { ISoftwareVulnerability } from "interfaces/software"; + import Modal from "components/Modal"; import Button from "components/buttons/Button"; import CustomLink from "components/CustomLink"; @@ -12,9 +13,21 @@ const baseClass = "preview-data-modal"; interface IPreviewPayloadModalProps { onCancel: () => void; } + +interface IHostsAffected { + id: number; + display_name: string; + url: string; + software_installed_paths?: string[]; +} + +type IWebhookPayload = { + hosts_affected?: IHostsAffected[] | null; +} & ISoftwareVulnerability; + interface IJsonPayload { timestamp: string; - vulnerability: IVulnerability; + vulnerability: IWebhookPayload; } const PreviewPayloadModal = ({ diff --git a/frontend/pages/software/ManageSoftwarePage/components/PreviewPayloadModal/_styles.scss b/frontend/pages/SoftwarePage/components/PreviewPayloadModal/_styles.scss similarity index 100% rename from frontend/pages/software/ManageSoftwarePage/components/PreviewPayloadModal/_styles.scss rename to frontend/pages/SoftwarePage/components/PreviewPayloadModal/_styles.scss diff --git a/frontend/pages/software/ManageSoftwarePage/components/PreviewPayloadModal/index.ts b/frontend/pages/SoftwarePage/components/PreviewPayloadModal/index.ts similarity index 100% rename from frontend/pages/software/ManageSoftwarePage/components/PreviewPayloadModal/index.ts rename to frontend/pages/SoftwarePage/components/PreviewPayloadModal/index.ts diff --git a/frontend/pages/software/ManageSoftwarePage/components/PreviewTicketModal/PreviewTicketModal.tsx b/frontend/pages/SoftwarePage/components/PreviewTicketModal/PreviewTicketModal.tsx similarity index 79% rename from frontend/pages/software/ManageSoftwarePage/components/PreviewTicketModal/PreviewTicketModal.tsx rename to frontend/pages/SoftwarePage/components/PreviewTicketModal/PreviewTicketModal.tsx index f531d2023d..4197119b64 100644 --- a/frontend/pages/software/ManageSoftwarePage/components/PreviewTicketModal/PreviewTicketModal.tsx +++ b/frontend/pages/SoftwarePage/components/PreviewTicketModal/PreviewTicketModal.tsx @@ -6,10 +6,10 @@ import Modal from "components/Modal"; import Button from "components/buttons/Button"; import CustomLink from "components/CustomLink"; -import JiraPreview from "../../../../../../assets/images/jira-vuln-software-preview-400x517@2x.png"; -import ZendeskPreview from "../../../../../../assets/images/zendesk-vuln-software-preview-400x455@2x.png"; -import JiraPreviewPremium from "../../../../../../assets/images/jira-vuln-software-preview-premium-400x517@2x.png"; -import ZendeskPreviewPremium from "../../../../../../assets/images/zendesk-vuln-software-preview-premium-400x455@2x.png"; +import JiraPreview from "../../../../../assets/images/jira-vuln-software-preview-400x517@2x.png"; +import ZendeskPreview from "../../../../../assets/images/zendesk-vuln-software-preview-400x455@2x.png"; +import JiraPreviewPremium from "../../../../../assets/images/jira-vuln-software-preview-premium-400x517@2x.png"; +import ZendeskPreviewPremium from "../../../../../assets/images/zendesk-vuln-software-preview-premium-400x455@2x.png"; const baseClass = "preview-ticket-modal"; diff --git a/frontend/pages/software/ManageSoftwarePage/components/PreviewTicketModal/_styles.scss b/frontend/pages/SoftwarePage/components/PreviewTicketModal/_styles.scss similarity index 100% rename from frontend/pages/software/ManageSoftwarePage/components/PreviewTicketModal/_styles.scss rename to frontend/pages/SoftwarePage/components/PreviewTicketModal/_styles.scss diff --git a/frontend/pages/software/ManageSoftwarePage/components/PreviewTicketModal/index.ts b/frontend/pages/SoftwarePage/components/PreviewTicketModal/index.ts similarity index 100% rename from frontend/pages/software/ManageSoftwarePage/components/PreviewTicketModal/index.ts rename to frontend/pages/SoftwarePage/components/PreviewTicketModal/index.ts diff --git a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx new file mode 100644 index 0000000000..f1b5a39aa1 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx @@ -0,0 +1,69 @@ +import ViewAllHostsLink from "components/ViewAllHostsLink"; +import React from "react"; +import SoftwareIcon from "../icons/SoftwareIcon"; + +const baseClass = "software-details-summary"; + +interface IDescriptionSetProps { + title: string; + value: React.ReactNode; +} + +// TODO: move to frontend/components +const DataSet = ({ title, value }: IDescriptionSetProps) => { + return ( +
+
{title}
+
{value}
+
+ ); +}; + +interface ISoftwareDetailsSummaryProps { + id: number; + title: string; + type: string; + hosts: number; + /** The query param name that will be added when user clicks on "View all hosts" link */ + queryParam: string; + name?: string; + source?: string; + versions?: number; +} + +const SoftwareDetailsSummary = ({ + id, + title, + type, + hosts, + queryParam, + name, + source, + versions, +}: ISoftwareDetailsSummaryProps) => { + return ( +
+ +
+

{title}

+
+ + {versions && } + +
+
+
+ +
+
+ ); +}; + +export default SoftwareDetailsSummary; diff --git a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/_styles.scss b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/_styles.scss new file mode 100644 index 0000000000..a0456417b1 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/_styles.scss @@ -0,0 +1,40 @@ +.software-details-summary { + background-color: $core-white; + padding: $pad-xxlarge; + border: 1px solid $ui-fleet-black-10; + border-radius: $border-radius-xxlarge; + box-shadow: $box-shadow; + display: flex; + gap: $pad-medium; + + &__icon { + width: 96px; + height: 96px; + border: 1px solid #E2E4EA; + border-radius: 8px; + } + + &__info { + flex-grow: 1; + } + + h1 { + font-size: $pad-large; + font-weight: bold; + margin-bottom: $pad-medium; + } + + + &__description-list { + display: flex; + gap: $pad-xxlarge; + } + + &__data-set { + font-size: $x-small; + + dt { + font-weight: $bold; + } + } +} diff --git a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/index.ts b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/index.ts new file mode 100644 index 0000000000..0fe352aa8f --- /dev/null +++ b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwareDetailsSummary"; diff --git a/frontend/pages/SoftwarePage/components/VersionCell/VersionCell.tsx b/frontend/pages/SoftwarePage/components/VersionCell/VersionCell.tsx new file mode 100644 index 0000000000..db8f4370c9 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/VersionCell/VersionCell.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { uniqueId } from "lodash"; + +import { ISoftwareTitleVersion } from "interfaces/software"; + +import TextCell from "components/TableContainer/DataTable/TextCell"; +import ReactTooltip from "react-tooltip"; + +const baseClass = "version-cell"; + +const generateText = (versions: ISoftwareTitleVersion[]) => { + const text = + versions.length !== 1 ? `${versions.length} versions` : versions[0].version; + return ; +}; + +const generateTooltip = ( + versions: ISoftwareTitleVersion[], + tooltipId: string +) => { + if (versions.length <= 1) { + return null; + } + + const versionNames = versions.map((version) => version.version); + + return ( + +

{versionNames.join(", ")}

+
+ ); +}; + +interface IVersionCellProps { + versions: ISoftwareTitleVersion[]; +} + +const VersionCell = ({ versions }: IVersionCellProps) => { + const tooltipId = uniqueId(); + + // only one version, no need for tooltip + const cellText = generateText(versions); + if (versions.length <= 1) { + return <>{cellText}; + } + + const versionTooltip = generateTooltip(versions, tooltipId); + return ( + <> +
+ {cellText} +
+ {versionTooltip} + + ); +}; + +export default VersionCell; diff --git a/frontend/pages/SoftwarePage/components/VersionCell/_styles.scss b/frontend/pages/SoftwarePage/components/VersionCell/_styles.scss new file mode 100644 index 0000000000..2f0275a50d --- /dev/null +++ b/frontend/pages/SoftwarePage/components/VersionCell/_styles.scss @@ -0,0 +1,10 @@ +.version-cell { + + &__version-text-with-tooltip { + display: inline-block; + } + + &__versions { + margin: 0; + } +} diff --git a/frontend/pages/SoftwarePage/components/VersionCell/index.ts b/frontend/pages/SoftwarePage/components/VersionCell/index.ts new file mode 100644 index 0000000000..bd8a77e877 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/VersionCell/index.ts @@ -0,0 +1 @@ +export { default } from "./VersionCell"; diff --git a/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/VulnerabilitiesCell.tsx b/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/VulnerabilitiesCell.tsx new file mode 100644 index 0000000000..51183cacda --- /dev/null +++ b/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/VulnerabilitiesCell.tsx @@ -0,0 +1,114 @@ +import React from "react"; +import { uniqueId } from "lodash"; +import ReactTooltip from "react-tooltip"; + +import TextCell from "components/TableContainer/DataTable/TextCell"; +import { ISoftwareVulnerability } from "interfaces/software"; + +const NUM_VULNERABILITIES_IN_TOOLTIP = 3; + +const baseClass = "vulnerabilities-cell"; + +const generateCell = ( + vulnerabilities: ISoftwareVulnerability[] | string[] | null +) => { + if (vulnerabilities === null) { + return ; + } + + let text = ""; + let isGrayed = true; + if (vulnerabilities.length === 0) { + text = "---"; + } else if (vulnerabilities.length === 1) { + isGrayed = false; + text = + typeof vulnerabilities[0] === "string" + ? vulnerabilities[0] + : vulnerabilities[0].cve; + } else { + text = `${vulnerabilities.length} vulnerabilities`; + } + + return ; +}; + +const getName = (vulnerabiltiy: ISoftwareVulnerability | string) => { + return typeof vulnerabiltiy === "string" ? vulnerabiltiy : vulnerabiltiy.cve; +}; + +const condenseVulnerabilities = ( + vulnerabilities: ISoftwareVulnerability[] | string[] +) => { + const condensed = + (vulnerabilities?.length && + vulnerabilities + .slice(-NUM_VULNERABILITIES_IN_TOOLTIP) + .map(getName) + .reverse()) || + []; + + return vulnerabilities.length > NUM_VULNERABILITIES_IN_TOOLTIP + ? condensed.concat( + `+${vulnerabilities.length - NUM_VULNERABILITIES_IN_TOOLTIP} more` + ) + : condensed; +}; + +const generateTooltip = ( + vulnerabilities: ISoftwareVulnerability[] | string[], + tooltipId: string +) => { + if (vulnerabilities.length <= 1) { + return null; + } + + const condensedVulnerabilties = condenseVulnerabilities(vulnerabilities); + + return ( + +
    + {condensedVulnerabilties.map((vulnerability) => { + return
  • • {vulnerability}
  • ; + })} +
+
+ ); +}; +interface IVulnerabilitiesCellProps { + vulnerabilities: ISoftwareVulnerability[] | string[] | null; +} + +const VulnerabilitiesCell = ({ + vulnerabilities, +}: IVulnerabilitiesCellProps) => { + const tooltipId = uniqueId(); + + // only one vulnerability, no need for tooltip + const cell = generateCell(vulnerabilities); + if (vulnerabilities === null || vulnerabilities.length <= 1) { + return <>{cell}; + } + + const vulnerabilityTooltip = generateTooltip(vulnerabilities, tooltipId); + + return ( + <> +
+ {cell} +
+ {vulnerabilityTooltip} + + ); +}; + +export default VulnerabilitiesCell; diff --git a/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/_styles.scss b/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/_styles.scss new file mode 100644 index 0000000000..a1f3c87066 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/_styles.scss @@ -0,0 +1,12 @@ +.vulnerabilities-cell { + &__vulnerability-text-with-tooltip { + display: inline-block; + } + + &__vulnerability-list { + margin: 0; + padding: 0; + list-style: none; + text-align: left; + } +} diff --git a/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/index.ts b/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/index.ts new file mode 100644 index 0000000000..82f8dfb1aa --- /dev/null +++ b/frontend/pages/SoftwarePage/components/VulnerabilitiesCell/index.ts @@ -0,0 +1 @@ +export { default } from "./VulnerabilitiesCell"; diff --git a/frontend/pages/SoftwarePage/components/icons/AcrobatReader.tsx b/frontend/pages/SoftwarePage/components/icons/AcrobatReader.tsx new file mode 100644 index 0000000000..440db6f583 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/AcrobatReader.tsx @@ -0,0 +1,17 @@ +import React from "react"; + +import type { SVGProps } from "react"; + +const AcrobatReader = (props: SVGProps) => ( + + + + +); +export default AcrobatReader; diff --git a/frontend/pages/SoftwarePage/components/icons/Chrome.tsx b/frontend/pages/SoftwarePage/components/icons/Chrome.tsx new file mode 100644 index 0000000000..4a4df76d39 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/Chrome.tsx @@ -0,0 +1,71 @@ +import React from "react"; + +import type { SVGProps } from "react"; + +const Chrome = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + +); +export default Chrome; diff --git a/frontend/pages/SoftwarePage/components/icons/Excel.tsx b/frontend/pages/SoftwarePage/components/icons/Excel.tsx new file mode 100644 index 0000000000..5a46cfbc23 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/Excel.tsx @@ -0,0 +1,70 @@ +import React from "react"; + +import type { SVGProps } from "react"; + +const Excel = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + +); +export default Excel; diff --git a/frontend/pages/SoftwarePage/components/icons/Extension.tsx b/frontend/pages/SoftwarePage/components/icons/Extension.tsx new file mode 100644 index 0000000000..d456c8724f --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/Extension.tsx @@ -0,0 +1,20 @@ +import React from "react"; + +import type { SVGProps } from "react"; + +const Extension = (props: SVGProps) => ( + + + + +); +export default Extension; diff --git a/frontend/pages/SoftwarePage/components/icons/Firefox.tsx b/frontend/pages/SoftwarePage/components/icons/Firefox.tsx new file mode 100644 index 0000000000..be5594c4e6 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/Firefox.tsx @@ -0,0 +1,292 @@ +import React from "react"; + +import type { SVGProps } from "react"; + +const Firefox = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); +export default Firefox; diff --git a/frontend/pages/SoftwarePage/components/icons/MacApp.tsx b/frontend/pages/SoftwarePage/components/icons/MacApp.tsx new file mode 100644 index 0000000000..5119e03fb1 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/MacApp.tsx @@ -0,0 +1,20 @@ +import React from "react"; + +import type { SVGProps } from "react"; + +const MacApp = (props: SVGProps) => ( + + + + +); +export default MacApp; diff --git a/frontend/pages/SoftwarePage/components/icons/Package.tsx b/frontend/pages/SoftwarePage/components/icons/Package.tsx new file mode 100644 index 0000000000..8a2aae8db9 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/Package.tsx @@ -0,0 +1,20 @@ +import React from "react"; + +import type { SVGProps } from "react"; + +const Package = (props: SVGProps) => ( + + + + +); +export default Package; diff --git a/frontend/pages/SoftwarePage/components/icons/Safari.tsx b/frontend/pages/SoftwarePage/components/icons/Safari.tsx new file mode 100644 index 0000000000..c0c28e3a8c --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/Safari.tsx @@ -0,0 +1,584 @@ +import React from "react"; + +import type { SVGProps } from "react"; + +const Safari = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); +export default Safari; diff --git a/frontend/pages/SoftwarePage/components/icons/Slack.tsx b/frontend/pages/SoftwarePage/components/icons/Slack.tsx new file mode 100644 index 0000000000..3b6aac63d7 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/Slack.tsx @@ -0,0 +1,37 @@ +import React from "react"; + +import type { SVGProps } from "react"; + +const Slack = (props: SVGProps) => ( + + + + + + + + + + + + + + +); +export default Slack; diff --git a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/SoftwareIcon.tsx b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/SoftwareIcon.tsx new file mode 100644 index 0000000000..e99b23e262 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/SoftwareIcon.tsx @@ -0,0 +1,64 @@ +import React, { ComponentType, SVGProps } from "react"; +import { + SOFTWARE_NAME_TO_ICON_MAP, + SOFTWARE_SOURCE_TO_ICON_MAP, + SOFTWARE_ICON_SIZES, + SoftwareIconSizes, +} from "../"; + +const baseClass = "software-icon"; + +interface ISoftwareIconProps { + name?: string; + source?: string; + size?: SoftwareIconSizes; +} + +const matchInMap = ( + map: Record>>, + potentialKey?: string +) => { + if (!potentialKey) { + return null; + } + + const sanitizedKey = potentialKey.trim().toLowerCase(); + const match = Object.entries(map).find(([namePrefix, icon]) => { + if (sanitizedKey.startsWith(namePrefix)) { + return icon; + } + return null; + }); + + return match ? match[1] : null; +}; + +const SoftwareIcon = ({ + name, + source, + size = "medium", +}: ISoftwareIconProps) => { + // try to find a match for name + let MatchedIcon = matchInMap(SOFTWARE_NAME_TO_ICON_MAP, name); + + // otherwise, try to find a match for source + if (!MatchedIcon) { + MatchedIcon = matchInMap(SOFTWARE_SOURCE_TO_ICON_MAP, source); + } + + // default to 'package' + if (!MatchedIcon) { + MatchedIcon = SOFTWARE_SOURCE_TO_ICON_MAP.package; + } + + return ( + + ); +}; + +export default SoftwareIcon; diff --git a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/index.ts b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/index.ts new file mode 100644 index 0000000000..202b202e40 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwareIcon"; diff --git a/frontend/pages/SoftwarePage/components/icons/Teams.tsx b/frontend/pages/SoftwarePage/components/icons/Teams.tsx new file mode 100644 index 0000000000..dfb64170cb --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/Teams.tsx @@ -0,0 +1,84 @@ +import React from "react"; + +import type { SVGProps } from "react"; + +const Teams = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + +); +export default Teams; diff --git a/frontend/pages/SoftwarePage/components/icons/VisualStudioCode.tsx b/frontend/pages/SoftwarePage/components/icons/VisualStudioCode.tsx new file mode 100644 index 0000000000..ac089803e2 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/VisualStudioCode.tsx @@ -0,0 +1,37 @@ +import React from "react"; + +import type { SVGProps } from "react"; + +const VisualStudioCode = (props: SVGProps) => ( + + + + + + + + + + + + + + +); +export default VisualStudioCode; diff --git a/frontend/pages/SoftwarePage/components/icons/WindowsApp.tsx b/frontend/pages/SoftwarePage/components/icons/WindowsApp.tsx new file mode 100644 index 0000000000..6f0fad5a90 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/WindowsApp.tsx @@ -0,0 +1,20 @@ +import React from "react"; + +import type { SVGProps } from "react"; + +const WindowsApp = (props: SVGProps) => ( + + + + +); +export default WindowsApp; diff --git a/frontend/pages/SoftwarePage/components/icons/Word.tsx b/frontend/pages/SoftwarePage/components/icons/Word.tsx new file mode 100644 index 0000000000..4a654107b2 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/Word.tsx @@ -0,0 +1,94 @@ +import React from "react"; + +import type { SVGProps } from "react"; + +const Word = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); +export default Word; diff --git a/frontend/pages/SoftwarePage/components/icons/Zoom.tsx b/frontend/pages/SoftwarePage/components/icons/Zoom.tsx new file mode 100644 index 0000000000..8b9512751d --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/Zoom.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +import type { SVGProps } from "react"; + +const Zoom = (props: SVGProps) => ( + + + + + + + + + + +); +export default Zoom; diff --git a/frontend/pages/SoftwarePage/components/icons/index.ts b/frontend/pages/SoftwarePage/components/icons/index.ts new file mode 100644 index 0000000000..0965c78651 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/index.ts @@ -0,0 +1,60 @@ +import AcrobatReader from "./AcrobatReader"; +import Chrome from "./Chrome"; +import Excel from "./Excel"; +import Extension from "./Extension"; +import Firefox from "./Firefox"; +import MacApp from "./MacApp"; +import Package from "./Package"; +import Safari from "./Safari"; +import Slack from "./Slack"; +import Teams from "./Teams"; +import VisualStudioCode from "./VisualStudioCode"; +import WindowsApp from "./WindowsApp"; +import Word from "./Word"; +import Zoom from "./Zoom"; + +// SOFTWARE_NAME_TO_ICON_MAP list "special" applications that have a defined +// icon for them, keys refer to application names, and are intended to be fuzzy +// matched in the application logic. +export const SOFTWARE_NAME_TO_ICON_MAP = { + "adobe acrobat reader": AcrobatReader, + "google chrome": Chrome, + "microsoft excel": Excel, + firefox: Firefox, + package: Package, + safari: Safari, + slack: Slack, + "microsoft teams": Teams, + "visual studio code": VisualStudioCode, + "microsoft word": Word, + zoom: Zoom, +} as const; + +// SOFTWARE_SOURCE_TO_ICON_MAP maps different software sources to a defined +// icon. +export const SOFTWARE_SOURCE_TO_ICON_MAP = { + package: Package, + apt_sources: Package, + deb_packages: Package, + rpm_packages: Package, + yum_sources: Package, + npm_packages: Package, + atom_packages: Package, + python_packages: Package, + homebrew_packages: Package, + apps: MacApp, + programs: WindowsApp, + chrome_extensions: Extension, + safari_extensions: Extension, + firefox_addons: Extension, + ie_extensions: Extension, + chocolatey_packages: Package, + pkg_packages: Package, +} as const; + +export const SOFTWARE_ICON_SIZES: Record = { + medium: "24", + large: "96", +} as const; + +export type SoftwareIconSizes = keyof typeof SOFTWARE_ICON_SIZES; diff --git a/frontend/pages/SoftwarePage/helpers.ts b/frontend/pages/SoftwarePage/helpers.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/pages/SoftwarePage/index.ts b/frontend/pages/SoftwarePage/index.ts new file mode 100644 index 0000000000..980539c402 --- /dev/null +++ b/frontend/pages/SoftwarePage/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwarePage"; diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index 480175995c..577aa24f01 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -219,6 +219,14 @@ const ManageHostsPage = ({ queryParams?.software_id !== undefined ? parseInt(queryParams.software_id, 10) : undefined; + const softwareVersionId = + queryParams?.software_version_id !== undefined + ? parseInt(queryParams.software_version_id, 10) + : undefined; + const softwareTitleId = + queryParams?.software_title_id !== undefined + ? parseInt(queryParams.software_title_id, 10) + : undefined; const status = isAcceptableStatus(queryParams?.status) ? queryParams?.status : undefined; @@ -360,6 +368,8 @@ const ManageHostsPage = ({ policyId, policyResponse, softwareId, + softwareTitleId, + softwareVersionId, status, mdmId, mdmEnrollmentStatus, @@ -400,6 +410,8 @@ const ManageHostsPage = ({ policyId, policyResponse, softwareId, + softwareTitleId, + softwareVersionId, status, mdmId, mdmEnrollmentStatus, @@ -491,7 +503,13 @@ const ManageHostsPage = ({ // TODO: cleanup this effect useEffect(() => { - if (location.search.includes("software_id")) { + if ( + location.search.match( + /software_id|software_version_id|software_title_id/gi + ) + ) { + // regex matches any of "software_id", "software_version_id", or "software_title_id" + // so we don't set the filtered hosts path in those cases return; } const path = location.pathname + location.search; @@ -520,6 +538,8 @@ const ManageHostsPage = ({ "policy_id", "policy_response", "software_id", + "software_version_id", + "software_title_id", ]); } @@ -783,6 +803,10 @@ const ManageHostsPage = ({ newQueryParams.macos_settings = macSettingsStatus; } else if (softwareId) { newQueryParams.software_id = softwareId; + } else if (softwareVersionId) { + newQueryParams.software_version_id = softwareVersionId; + } else if (softwareTitleId) { + newQueryParams.software_title_id = softwareTitleId; } else if (mdmId) { newQueryParams.mdm_id = mdmId; } else if (mdmEnrollmentStatus) { @@ -828,6 +852,8 @@ const ManageHostsPage = ({ policyResponse, macSettingsStatus, softwareId, + softwareVersionId, + softwareTitleId, mdmId, mdmEnrollmentStatus, munkiIssueId, @@ -1244,6 +1270,8 @@ const ManageHostsPage = ({ policyResponse, macSettingsStatus, softwareId, + softwareTitleId, + softwareVersionId, status, mdmId, mdmEnrollmentStatus, @@ -1557,6 +1585,8 @@ const ManageHostsPage = ({ policy, macSettingsStatus, softwareId, + softwareTitleId, + softwareVersionId, mdmId, mdmEnrollmentStatus, lowDiskSpaceHosts, @@ -1566,7 +1596,8 @@ const ManageHostsPage = ({ osVersions, munkiIssueId, munkiIssueDetails: hostsData?.munki_issue || null, - softwareDetails: hostsData?.software || null, + softwareDetails: + hostsData?.software || hostsData?.software_title || null, mdmSolutionDetails: hostsData?.mobile_device_management_solution || null, osSettingsStatus, diff --git a/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx b/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx index 1c45076031..f504b6556d 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx +++ b/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx @@ -53,7 +53,9 @@ interface IHostsFilterBlockProps { policyId?: any; policy?: IPolicy; macSettingsStatus?: any; - softwareId?: any; + softwareId?: number; + softwareTitleId?: number; + softwareVersionId?: number; mdmId?: number; mdmEnrollmentStatus?: any; lowDiskSpaceHosts?: number; @@ -62,7 +64,7 @@ interface IHostsFilterBlockProps { osVersion?: any; munkiIssueId?: number; osVersions?: IOperatingSystemVersion[]; - softwareDetails: ISoftware | null; + softwareDetails: { name: string; version?: string } | null; mdmSolutionDetails: IMdmSolution | null; osSettingsStatus?: MdmProfileStatus; diskEncryptionStatus?: DiskEncryptionStatus; @@ -95,6 +97,8 @@ const HostsFilterBlock = ({ policyId, macSettingsStatus, softwareId, + softwareTitleId, + softwareVersionId, mdmId, mdmEnrollmentStatus, lowDiskSpaceHosts, @@ -235,21 +239,31 @@ const HostsFilterBlock = ({ if (!softwareDetails) return null; const { name, version } = softwareDetails; - const label = `${name || "Unknown software"} ${version || ""}`; + let label = name; + if (version) { + label += ` ${version}`; + } + label = label.trim() || "Unknown software"; - const TooltipDescription = ( - - Hosts with {name || "Unknown software"}, -
- {version || "version unknown"} installed -
- ); + // const TooltipDescription = ( + // + // Hosts with {name || "Unknown software"}, + //
+ // {version || "version unknown"} installed + //
+ // ); return ( handleClearFilter(["software_id"])} - tooltipDescription={TooltipDescription} + onClear={() => + handleClearFilter([ + "software_id", + "software_version_id", + "software_title_id", + ]) + } + // tooltipDescription={TooltipDescription} /> ); }; @@ -433,6 +447,8 @@ const HostsFilterBlock = ({ policyId || macSettingsStatus || softwareId || + softwareTitleId || + softwareVersionId || mdmId || mdmEnrollmentStatus || lowDiskSpaceHosts || @@ -472,7 +488,7 @@ const HostsFilterBlock = ({ return renderPoliciesFilterBlock(); case !!macSettingsStatus: return renderMacSettingsStatusFilterBlock(); - case !!softwareId: + case !!softwareId || !!softwareVersionId || !!softwareTitleId: return renderSoftwareFilterBlock(); case !!mdmId: return renderMDMSolutionFilterBlock(); diff --git a/frontend/pages/hosts/details/cards/Software/Software.tsx b/frontend/pages/hosts/details/cards/Software/Software.tsx index bb695fb725..05e3cf8a3a 100644 --- a/frontend/pages/hosts/details/cards/Software/Software.tsx +++ b/frontend/pages/hosts/details/cards/Software/Software.tsx @@ -13,7 +13,7 @@ import { buildQueryStringFromParams } from "utilities/url"; import Dropdown from "components/forms/fields/Dropdown"; import TableContainer from "components/TableContainer"; import { ITableQueryData } from "components/TableContainer/TableContainer"; -import EmptySoftwareTable from "pages/software/components/EmptySoftwareTable"; +import EmptySoftwareTable from "pages/SoftwarePage/components/EmptySoftwareTable"; import { getNextLocationPath } from "utilities/helpers"; import SoftwareVulnCount from "./SoftwareVulnCount"; diff --git a/frontend/pages/hosts/details/cards/Software/SoftwareTableConfig.tsx b/frontend/pages/hosts/details/cards/Software/SoftwareTableConfig.tsx index b95f38b3f8..8ce0179574 100644 --- a/frontend/pages/hosts/details/cards/Software/SoftwareTableConfig.tsx +++ b/frontend/pages/hosts/details/cards/Software/SoftwareTableConfig.tsx @@ -211,12 +211,12 @@ export const generateSoftwareTableHeaders = ({ // Allows for button to be clickable in a clickable row e.stopPropagation(); setFilteredSoftwarePath(pathname); - router?.push(PATHS.SOFTWARE_DETAILS(id.toString())); + router?.push(PATHS.SOFTWARE_VERSION_DETAILS(id.toString())); }; return ( { - const routeTemplate = route?.path ?? ""; - const queryParams = location.query; - const { - config: globalConfig, - isFreeTier, - isGlobalAdmin, - isGlobalMaintainer, - isOnGlobalTeam, - isPremiumTier, - isSandboxMode, - noSandboxHosts, - filteredSoftwarePath, - setFilteredSoftwarePath, - } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); - - const { - currentTeamId, - isAnyTeamSelected, - isRouteOk, - teamIdForApi, - userTeams, - handleTeamChange, - } = useTeamIdParam({ - location, - router, - includeAllTeams: true, - includeNoTeam: false, - }); - - const canManageAutomations = - isGlobalAdmin && (!isPremiumTier || !isAnyTeamSelected); - - const DEFAULT_SORT_HEADER = isPremiumTier ? "vulnerabilities" : "hosts_count"; - - const initialQuery = (() => { - let query = ""; - - if (queryParams && queryParams.query) { - query = queryParams.query; - } - - return query; - })(); - - const initialSortHeader = (() => { - let sortHeader = isPremiumTier ? "vulnerabilities" : "hosts_count"; - - if (queryParams && queryParams.order_key) { - sortHeader = queryParams.order_key; - } - - return sortHeader; - })(); - - const initialSortDirection = ((): "asc" | "desc" | undefined => { - let sortDirection = "desc"; - - if (queryParams && queryParams.order_direction) { - sortDirection = queryParams.order_direction; - } - - return sortDirection as "asc" | "desc" | undefined; - })(); - - const initialPage = (() => { - let page = 0; - - if (queryParams && queryParams.page) { - page = parseInt(queryParams.page, 10); - } - - return page; - })(); - - const initialVulnFilter = (() => { - let isFilteredByVulnerabilities = false; - - if (queryParams && queryParams.vulnerable === "true") { - isFilteredByVulnerabilities = true; - } - - return isFilteredByVulnerabilities; - })(); - - const [filterVuln, setFilterVuln] = useState(initialVulnFilter); - const [searchQuery, setSearchQuery] = useState(initialQuery); - const [sortDirection, setSortDirection] = useState< - "asc" | "desc" | undefined - >(initialSortDirection); - const [sortHeader, setSortHeader] = useState(initialSortHeader); - const [page, setPage] = useState(initialPage); - const [tableQueryData, setTableQueryData] = useState(); - const [resetPageIndex, setResetPageIndex] = useState(false); - const [showManageAutomationsModal, setShowManageAutomationsModal] = useState( - false - ); - const [showPreviewPayloadModal, setShowPreviewPayloadModal] = useState(false); - const [showPreviewTicketModal, setShowPreviewTicketModal] = useState(false); - - useEffect(() => { - setFilterVuln(initialVulnFilter); - setPage(initialPage); - setSearchQuery(initialQuery); - // TODO: handle invalid values for params - }, [location]); - - useEffect(() => { - const path = location.pathname + location.search; - if (filteredSoftwarePath !== path) { - setFilteredSoftwarePath(path); - } - }, [filteredSoftwarePath, location, setFilteredSoftwarePath]); - - // softwareConfig is either the global config or the team config of the currently selected team - const { - data: softwareConfig, - error: softwareConfigError, - isFetching: isFetchingSoftwareConfig, - refetch: refetchSoftwareConfig, - } = useQuery< - IConfig | ILoadTeamResponse, - Error, - IConfig | ITeamConfig, - ISoftwareConfigQueryKey[] - >( - [{ scope: "softwareConfig", teamId: teamIdForApi }], - ({ queryKey }) => { - const { teamId } = queryKey[0]; - return teamId ? teamsAPI.load(teamId) : configAPI.loadAll(); - }, - { - enabled: isRouteOk, - select: (data) => ("team" in data ? data.team : data), - } - ); - - const isSoftwareConfigLoaded = - !isFetchingSoftwareConfig && !softwareConfigError && !!softwareConfig; - - const isSoftwareEnabled = !!softwareConfig?.features - ?.enable_software_inventory; - - const vulnWebhookSettings = - softwareConfig?.webhook_settings?.vulnerabilities_webhook; - - const isVulnWebhookEnabled = !!vulnWebhookSettings?.enable_vulnerabilities_webhook; - - const isVulnIntegrationEnabled = (integrations?: IIntegrations) => { - return ( - !!integrations?.jira?.some((j) => j.enable_software_vulnerabilities) || - !!integrations?.zendesk?.some((z) => z.enable_software_vulnerabilities) - ); - }; - - const isAnyVulnAutomationEnabled = - isVulnWebhookEnabled || - isVulnIntegrationEnabled(softwareConfig?.integrations); - - const recentVulnerabilityMaxAge = (() => { - let maxAgeInNanoseconds: number | undefined; - if (softwareConfig && "vulnerabilities" in softwareConfig) { - maxAgeInNanoseconds = - softwareConfig.vulnerabilities.recent_vulnerability_max_age; - } else { - maxAgeInNanoseconds = - globalConfig?.vulnerabilities.recent_vulnerability_max_age; - } - return maxAgeInNanoseconds - ? Math.round(maxAgeInNanoseconds / 86400000000000) // convert from nanoseconds to days - : CONFIG_DEFAULT_RECENT_VULNERABILITY_MAX_AGE_IN_DAYS; - })(); - - const { - data: software, - error: softwareError, - isFetching: isFetchingSoftware, - } = useQuery< - ISoftwareResponse, - Error, - ISoftwareResponse, - ISoftwareQueryKey[] - >( - [ - { - scope: "software", - page: tableQueryData?.pageIndex, - perPage: DEFAULT_PAGE_SIZE, - query: searchQuery, - orderDirection: sortDirection, - // API expects "epss_probability" rather than "vulnerabilities" - orderKey: - isPremiumTier && sortHeader === "vulnerabilities" - ? "epss_probability" - : sortHeader, - teamId: teamIdForApi, - vulnerable: filterVuln, - }, - ], - ({ queryKey }) => softwareAPI.load(queryKey[0]), - { - enabled: isRouteOk && isSoftwareConfigLoaded, - keepPreviousData: true, - staleTime: 30000, // stale time can be adjusted if fresher data is desired based on software inventory interval - } - ); - - const { - data: softwareCount, - error: softwareCountError, - isFetching: isFetchingCount, - } = useQuery( - [ - { - scope: "softwareCount", - query: searchQuery, - vulnerable: filterVuln, - teamId: teamIdForApi, - }, - ], - ({ queryKey }) => softwareAPI.getCount(queryKey[0]), - { - enabled: isRouteOk && isSoftwareConfigLoaded, - keepPreviousData: true, - staleTime: 30000, // stale time can be adjusted if fresher data is desired based on software inventory interval - refetchOnWindowFocus: false, - retry: 1, - select: (data) => data.count, - } - ); - - // NOTE: this is called once on initial render and every time the query changes - const onQueryChange = useCallback( - async (newTableQuery: ITableQueryData) => { - if (!isRouteOk || isEqual(newTableQuery, tableQueryData)) { - return; - } - - setTableQueryData({ ...newTableQuery }); - - const { - pageIndex, - searchQuery: newSearchQuery, - sortDirection: newSortDirection, - } = newTableQuery; - let { sortHeader: newSortHeader } = newTableQuery; - - pageIndex !== page && setPage(pageIndex); - searchQuery !== newSearchQuery && setSearchQuery(newSearchQuery); - sortDirection !== newSortDirection && - setSortDirection( - newSortDirection === "asc" || newSortDirection === "desc" - ? newSortDirection - : DEFAULT_SORT_DIRECTION - ); - - if (isPremiumTier && newSortHeader === "vulnerabilities") { - newSortHeader = "epss_probability"; - } - sortHeader !== newSortHeader && setSortHeader(newSortHeader); - - // Rebuild queryParams to dispatch new browser location to react-router - const newQueryParams: { [key: string]: string | number | undefined } = {}; - if (!isEmpty(newSearchQuery)) { - newQueryParams.query = newSearchQuery; - } - newQueryParams.page = pageIndex; - newQueryParams.order_key = newSortHeader || DEFAULT_SORT_HEADER; - newQueryParams.order_direction = - newSortDirection || DEFAULT_SORT_DIRECTION; - - newQueryParams.vulnerable = filterVuln ? "true" : undefined; - - if (teamIdForApi !== undefined) { - newQueryParams.team_id = teamIdForApi; - } - - const locationPath = getNextLocationPath({ - pathPrefix: PATHS.MANAGE_SOFTWARE, - routeTemplate, - queryParams: newQueryParams, - }); - router.replace(locationPath); - }, - [ - isRouteOk, - teamIdForApi, - tableQueryData, - page, - searchQuery, - sortDirection, - isPremiumTier, - sortHeader, - DEFAULT_SORT_HEADER, - filterVuln, - routeTemplate, - router, - ] - ); - - const toggleManageAutomationsModal = useCallback(() => { - setShowManageAutomationsModal(!showManageAutomationsModal); - }, [setShowManageAutomationsModal, showManageAutomationsModal]); - - const togglePreviewPayloadModal = useCallback(() => { - setShowPreviewPayloadModal(!showPreviewPayloadModal); - }, [setShowPreviewPayloadModal, showPreviewPayloadModal]); - - const togglePreviewTicketModal = useCallback(() => { - setShowPreviewTicketModal(!showPreviewTicketModal); - }, [setShowPreviewTicketModal, showPreviewTicketModal]); - - const onCreateWebhookSubmit = async ( - configSoftwareAutomations: ISoftwareAutomations - ) => { - try { - const request = configAPI.update(configSoftwareAutomations); - await request.then(() => { - renderFlash( - "success", - "Successfully updated vulnerability automations." - ); - refetchSoftwareConfig(); - }); - } catch { - renderFlash( - "error", - "Could not update vulnerability automations. Please try again." - ); - } finally { - toggleManageAutomationsModal(); - } - }; - - const onTeamChange = useCallback( - (teamId: number) => { - handleTeamChange(teamId); - setPage(0); - }, - [handleTeamChange] - ); - - // NOTE: used to reset page number to 0 when modifying filters - const handleResetPageIndex = () => { - setTableQueryData( - (prevState) => - ({ - ...prevState, - pageIndex: 0, - } as ITableQueryData) - ); - setResetPageIndex(true); - }; - - // NOTE: used to reset page number to 0 when modifying filters - useEffect(() => { - // TODO: cleanup this effect - setResetPageIndex(false); - }, [queryParams]); - - const renderHeaderDescription = () => { - return ( -

- Search for installed software{" "} - {(isGlobalAdmin || isGlobalMaintainer) && - (!isPremiumTier || !isAnyTeamSelected) && - "and manage automations for detected vulnerabilities (CVEs)"}{" "} - on{" "} - - {isPremiumTier && isAnyTeamSelected - ? "all hosts assigned to this team" - : "all of your hosts"} - - . -

- ); - }; - - const renderSoftwareCount = useCallback(() => { - const count = softwareCount; - const lastUpdatedAt = software?.counts_updated_at; - - if (!isSoftwareEnabled || !lastUpdatedAt) { - return null; - } - - if (softwareCountError && !isFetchingCount) { - return ( - - Failed to load software count - - ); - } - - if (count) { - return ( -
- {`${count} software item${count === 1 ? "" : "s"}`} - -
- ); - } - - return null; - }, [ - isFetchingCount, - software, - softwareCountError, - softwareCount, - isSoftwareEnabled, - ]); - - const handleVulnFilterDropdownChange = (isFilterVulnerable: string) => { - handleResetPageIndex(); - - router.replace( - getNextLocationPath({ - pathPrefix: PATHS.MANAGE_SOFTWARE, - routeTemplate, - queryParams: { - ...queryParams, - vulnerable: isFilterVulnerable, - page: 0, // resets page index - }, - }) - ); - }; - - const renderVulnFilterDropdown = () => { - return ( - - ); - }; - - const renderTableFooter = () => { - return ( -
- Seeing unexpected software or vulnerabilities?{" "} - -
- ); - }; - - // TODO: Rework after backend is adjusted to differentiate empty search/filter results from - // collecting inventory - const isCollectingInventory = - !searchQuery && - !filterVuln && - page === 0 && - !software?.software && - software?.counts_updated_at === null; - - const isLastPage = - tableQueryData && - !!softwareCount && - DEFAULT_PAGE_SIZE * page + (software?.software?.length || 0) >= - softwareCount; - - const softwareTableHeaders = useMemo( - () => - generateSoftwareTableHeaders( - router, - isPremiumTier, - isSandboxMode, - currentTeamId - ), - [isPremiumTier, isSandboxMode, router, currentTeamId] - ); - const handleRowSelect = (row: IRowProps) => { - const hostsBySoftwareParams = { - software_id: row.original.id, - team_id: currentTeamId, - }; - - const path = hostsBySoftwareParams - ? `${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams( - hostsBySoftwareParams - )}` - : PATHS.MANAGE_HOSTS; - - router.push(path); - }; - - const searchable = - isSoftwareEnabled && - (!!software?.software || - searchQuery !== "" || - queryParams.vulnerable === "true"); - - const renderSoftwareTable = () => { - if ( - (softwareError && !isFetchingSoftware) || - (softwareConfigError && !isFetchingSoftwareConfig) - ) { - return ; - } - return ( - ( - - )} - defaultSortHeader={sortHeader || DEFAULT_SORT_HEADER} - defaultSortDirection={sortDirection || DEFAULT_SORT_DIRECTION} - defaultPageIndex={page || 0} - defaultSearchQuery={searchQuery} - manualSortBy - pageSize={DEFAULT_PAGE_SIZE} - showMarkAllPages={false} - isAllPagesSelected={false} - resetPageIndex={resetPageIndex} - disableNextPage={isLastPage} - searchable={searchable} - inputPlaceHolder="Search by name or vulnerabilities (CVEs)" - onQueryChange={onQueryChange} - additionalQueries={filterVuln ? "vulnerable" : ""} // additionalQueries serves as a trigger - // for the useDeepEffect hook to fire onQueryChange for events happeing outside of - // the TableContainer - customControl={searchable ? renderVulnFilterDropdown : undefined} - stackControls - renderCount={renderSoftwareCount} - renderFooter={renderTableFooter} - disableMultiRowSelect - onSelectSingleRow={handleRowSelect} - /> - ); - }; - - return ( - -
-
-
-
-
- {isFreeTier &&

Software

} - {isPremiumTier && - ((userTeams && userTeams.length > 1) || isOnGlobalTeam) && ( - - )} - {isPremiumTier && - !isOnGlobalTeam && - userTeams && - userTeams.length === 1 &&

{userTeams[0].name}

} -
-
-
- {canManageAutomations && !softwareError && isSoftwareConfigLoaded && ( - - )} -
-
- {renderHeaderDescription()} -
-
{renderSoftwareTable()}
- {showManageAutomationsModal && ( - - )} -
-
- ); -}; - -export default ManageSoftwarePage; diff --git a/frontend/pages/software/ManageSoftwarePage/SoftwareTableConfig.tsx b/frontend/pages/software/ManageSoftwarePage/SoftwareTableConfig.tsx deleted file mode 100644 index 531479d2eb..0000000000 --- a/frontend/pages/software/ManageSoftwarePage/SoftwareTableConfig.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import React from "react"; -import { Column } from "react-table"; -import { InjectedRouter } from "react-router"; -import ReactTooltip from "react-tooltip"; - -import { formatSoftwareType, ISoftware } from "interfaces/software"; -import { IVulnerability } from "interfaces/vulnerability"; -import PATHS from "router/paths"; -import { - formatFloatAsPercentage, - getSoftwareBundleTooltipJSX, -} from "utilities/helpers"; -import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; - -import HeaderCell from "components/TableContainer/DataTable/HeaderCell"; -import TextCell from "components/TableContainer/DataTable/TextCell"; -import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell"; -import TooltipWrapper from "components/TooltipWrapper"; -import ViewAllHostsLink from "components/ViewAllHostsLink"; -import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip"; - -// NOTE: cellProps come from react-table -// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties -interface ICellProps { - cell: { - 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; - isSortedDesc: boolean; - }; -} - -const condenseVulnerabilities = ( - vulnerabilities: IVulnerability[] -): string[] => { - const condensed = - (vulnerabilities?.length && - vulnerabilities - .slice(-3) - .map((v) => v.cve) - .reverse()) || - []; - return vulnerabilities.length > 3 - ? condensed.concat(`+${vulnerabilities.length - 3} more`) - : condensed; -}; - -const getMaxProbability = (vulns: IVulnerability[]) => - vulns.reduce( - (max, { epss_probability }) => Math.max(max, epss_probability || 0), - 0 - ); - -const generateEPSSColumnHeader = (isSandboxMode = false) => { - return { - Header: (headerProps: IHeaderProps): JSX.Element => { - const titleWithToolTip = ( - - The probability that this software will be exploited -
- in the next 30 days (EPSS probability). This data is -
- reported by FIRST.org. - - } - > - Probability of exploit -
- ); - return ( - <> - {isSandboxMode && } - - - ); - }, - disableSortBy: false, - accessor: "vulnerabilities", - Cell: (cellProps: IVulnCellProps): JSX.Element => { - const vulns = cellProps.cell.value || []; - const maxProbability = (!!vulns.length && getMaxProbability(vulns)) || 0; - const displayValue = - (maxProbability && formatFloatAsPercentage(maxProbability)) || - DEFAULT_EMPTY_CELL_VALUE; - - return ( - - {displayValue} - - ); - }, - }; -}; - -const generateVulnColumnHeader = () => { - return { - title: "Vulnerabilities", - Header: "Vulnerabilities", - disableSortBy: true, - accessor: "vulnerabilities", - Cell: (cellProps: IVulnCellProps): JSX.Element => { - const vulnerabilities = cellProps.cell.value || []; - const tooltipText = condenseVulnerabilities(vulnerabilities)?.map( - (value) => { - return ( - - {value} -
-
- ); - } - ); - - if (!vulnerabilities?.length) { - return ---; - } - return ( - <> - 1 ? "text-muted tooltip" : "" - }`} - data-tip - data-for={`vulnerabilities__${cellProps.row.original.id}`} - data-tip-disable={vulnerabilities.length <= 1} - > - {vulnerabilities.length === 1 - ? vulnerabilities[0].cve - : `${vulnerabilities.length} vulnerabilities`} - - - - {tooltipText} - - - - ); - }, - }; -}; - -const generateTableHeaders = ( - router: InjectedRouter, - isPremiumTier?: boolean, - isSandboxMode?: boolean, - teamId?: number -): Column[] => { - const softwareTableHeaders = [ - { - title: "Name", - Header: (cellProps: IHeaderProps): JSX.Element => ( - - ), - disableSortBy: false, - accessor: "name", - Cell: (cellProps: IStringCellProps): JSX.Element => { - const { id, name, bundle_identifier: bundle } = cellProps.row.original; - - const onClickSoftware = (e: React.MouseEvent) => { - // Allows for button to be clickable in a clickable row - e.stopPropagation(); - - router?.push(PATHS.SOFTWARE_DETAILS(id.toString())); - }; - - return ( - - ); - }, - sortType: "caseInsensitive", - }, - { - title: "Version", - Header: "Version", - disableSortBy: true, - accessor: "version", - Cell: (cellProps: IStringCellProps): JSX.Element => ( - - ), - }, - { - title: "Type", - Header: "Type", - disableSortBy: true, - accessor: "source", - Cell: (cellProps: IStringCellProps): JSX.Element => ( - - ), - }, - isPremiumTier - ? generateEPSSColumnHeader(isSandboxMode) - : generateVulnColumnHeader(), - { - title: "Hosts", - Header: (cellProps: IHeaderProps): JSX.Element => ( - - ), - disableSortBy: false, - accessor: "hosts_count", - Cell: (cellProps: INumberCellProps): JSX.Element => ( - - - - - - - - - ), - }, - ]; - - return softwareTableHeaders; -}; - -export default generateTableHeaders; diff --git a/frontend/pages/software/ManageSoftwarePage/_styles.scss b/frontend/pages/software/ManageSoftwarePage/_styles.scss deleted file mode 100644 index ad2d32f106..0000000000 --- a/frontend/pages/software/ManageSoftwarePage/_styles.scss +++ /dev/null @@ -1,222 +0,0 @@ -.manage-software-page { - &__header-wrap { - display: flex; - align-items: center; - justify-content: space-between; - height: 38px; - - .button-wrap { - display: flex; - justify-content: flex-end; - min-width: 266px; - } - } - - &__manage-automations { - padding: $pad-small $pad-medium; - } - - &__count { - display: flex; - gap: 12px; - } - - &__header { - display: flex; - align-items: center; - - .form-field { - margin-bottom: 0; - } - } - - &__text { - margin-right: $pad-large; - } - - &__title { - font-size: $large; - } - - &__description { - margin: 0; - margin-bottom: $pad-large; - max-width: 75%; - @media (min-width: $break-md) { - max-width: none; - } - - h2 { - text-transform: uppercase; - color: $core-fleet-black; - font-weight: $regular; - font-size: $small; - } - - p { - color: $ui-fleet-black-75; - margin: 0; - font-size: $x-small; - font-style: italic; - } - } - - &__table { - .table-container { - &__header { - flex-direction: column-reverse; // Search bar on top - - @media (min-width: $break-md) { - flex-direction: row; - } - } - - &__header-left { - flex-direction: row; // Filter dropdown aligned with count - .controls { - .form-field--dropdown { - margin: 0; - } - .manage-software-page__vuln_dropdown { - .Select-menu-outer { - width: 250px; - max-height: 310px; - .Select-menu { - max-height: none; - } - } - .Select-value { - padding-left: $pad-medium; - padding-right: $pad-medium; - } - .dropdown__custom-value-label { - width: 155px; // Override 105px for longer text options - } - } - } - } - &__search-input, - &__search { - width: 100%; // Search bar across entire table - .input-icon-field__input { - width: 100%; - } - @media (min-width: $break-md) { - width: auto; - .input-icon-field__input { - width: 375px; - } - } - } - &__data-table-block { - .data-table-block { - .data-table__table { - tr { - .software-link { - opacity: 0; - transition: opacity 250ms; - } - - &:hover { - .software-link { - opacity: 1; - } - } - } - - thead { - .name__header { - width: $col-md; - } - .version__header { - width: 0; - } - .vulnerabilities__header { - display: none; - width: 0; - } - .source__header { - display: none; - width: 0; - } - .hosts_count__header { - width: auto; - border-right: 0; - } - @media (min-width: $break-md) { - .vulnerabilities__header { - display: table-cell; - } - } - @media (min-width: $break-lg) { - .version__header { - width: $col-md; - } - .source__header { - display: table-cell; - } - } - } - - tbody { - .name__cell { - max-width: $col-md; - // Tooltip does not get cut off - .children-wrapper { - overflow: initial; - } - } - .version__cell { - width: 0; - } - .source__cell { - display: none; - width: 0; - } - .vulnerabilities__cell { - display: none; - width: 0; - span { - display: inline; - } - .text-muted { - color: $ui-fleet-black-50; - } - } - .hosts_count__cell { - width: auto; - .hosts-cell__wrapper { - display: flex; - align-items: center; - justify-content: space-between; - .hosts-cell__link { - display: flex; - white-space: nowrap; - } - } - } - @media (min-width: $break-sm) { - .name__cell { - max-width: $col-lg; - } - } - @media (min-width: $break-md) { - .vulnerabilities__cell { - display: table-cell; - } - } - @media (min-width: $break-lg) { - .version_cell { - width: $col-md; - } - .source__cell { - display: table-cell; - } - } - } - } - } - } - } - } -} diff --git a/frontend/pages/software/ManageSoftwarePage/index.ts b/frontend/pages/software/ManageSoftwarePage/index.ts deleted file mode 100644 index a10fa5ef7a..0000000000 --- a/frontend/pages/software/ManageSoftwarePage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./ManageSoftwarePage"; diff --git a/frontend/pages/software/SoftwareDetailsPage/SoftwareDetailsPage.tsx b/frontend/pages/software/SoftwareDetailsPage/SoftwareDetailsPage.tsx deleted file mode 100644 index 7f174f258e..0000000000 --- a/frontend/pages/software/SoftwareDetailsPage/SoftwareDetailsPage.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import React, { useContext, useEffect } from "react"; -import { useErrorHandler } from "react-error-boundary"; -import { useQuery } from "react-query"; -import PATHS from "router/paths"; - -import { AppContext } from "context/app"; -import { - formatSoftwareType, - ISoftware, - IGetSoftwareByIdResponse, -} from "interfaces/software"; -import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team"; -import softwareAPI from "services/entities/software"; -import hostCountAPI from "services/entities/host_count"; - -import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; -import Spinner from "components/Spinner"; -import BackLink from "components/BackLink"; -import MainContent from "components/MainContent"; -import ViewAllHostsLink from "components/ViewAllHostsLink"; -import Vulnerabilities from "./components/Vulnerabilities"; - -const baseClass = "software-details-page"; - -interface ISoftwareDetailsProps { - params: { - software_id: string; - }; -} - -const SoftwareDetailsPage = ({ - params: { software_id }, -}: ISoftwareDetailsProps): JSX.Element => { - const { - isPremiumTier, - isSandboxMode, - currentTeam, - filteredSoftwarePath, - } = useContext(AppContext); - - const handlePageError = useErrorHandler(); - - const { data: software, isFetching: isFetchingSoftware } = useQuery< - IGetSoftwareByIdResponse, - Error, - ISoftware - >( - ["softwareById", software_id], - () => softwareAPI.getSoftwareById(software_id), - { - select: (data) => data.software, - onError: (err) => handlePageError(err), - } - ); - - const { data: hostCount } = useQuery<{ count: number }, Error, number>( - ["hostCountBySoftwareId", software_id], - () => hostCountAPI.load({ softwareId: parseInt(software_id, 10) }), - { select: (data) => data.count } - ); - - const renderName = (sw: ISoftware) => { - const { name, version } = sw; - if (!name) { - return "--"; - } - if (!version) { - return name; - } - - return `${name}, ${version}`; - }; - - // Updates title that shows up on browser tabs - useEffect(() => { - // e.g., Software horizon, 5.2.0 details | Fleet for osquery - document.title = `Software details | ${ - software && renderName(software) - } | Fleet for osquery`; - }, [location.pathname, software]); - - if (!software || isPremiumTier === undefined) { - return ; - } - - // Function instead of constant eliminates race condition with filteredSoftwarePath - const backToSoftwarePath = () => { - if (filteredSoftwarePath) { - return filteredSoftwarePath; - } - return currentTeam && currentTeam?.id > APP_CONTEXT_NO_TEAM_ID - ? `${PATHS.MANAGE_SOFTWARE}?team_id=${currentTeam?.id}` - : PATHS.MANAGE_SOFTWARE; - }; - - return ( - -
-
- -
-
-
-
-

{renderName(software)}

-
-
- -
-
-
-
-
- Type - - {formatSoftwareType(software.source)} - -
-
- Hosts - - {hostCount || DEFAULT_EMPTY_CELL_VALUE} - -
-
-
-
- -
-
- ); -}; - -export default SoftwareDetailsPage; diff --git a/frontend/pages/software/SoftwareDetailsPage/_styles.scss b/frontend/pages/software/SoftwareDetailsPage/_styles.scss deleted file mode 100644 index 3d8a569222..0000000000 --- a/frontend/pages/software/SoftwareDetailsPage/_styles.scss +++ /dev/null @@ -1,80 +0,0 @@ -.software-details-page { - background-color: $ui-off-white; - - &__wrapper { - display: grid; - gap: $pad-medium; - } - - .header { - flex: 100%; - display: flex; - flex-direction: column; - } - - .section { - flex: 100%; - display: flex; - flex-direction: column; - background-color: $core-white; - border-radius: 16px; - border: 1px solid $ui-fleet-black-10; - padding: $pad-xxlarge; - box-shadow: 0px 3px 0px rgba(226, 228, 234, 0.4); - - &__header { - font-size: $medium; - font-weight: $bold; - margin: 0 0 $pad-large 0; - } - - .info-flex { - display: flex; - flex-wrap: wrap; - - .info-flex__item--title { - margin-bottom: 2.5rem; - } - - &__item { - font-size: $x-small; - display: flex; - flex-direction: column; - white-space: nowrap; - - &--title { - margin-right: $pad-xxlarge; - - .info-flex__data { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - } - - &__header { - color: $core-fleet-black; - font-weight: $bold; - } - } - } - - .title, - .info { - flex-direction: row; - justify-content: space-between; - margin: 0; - padding-bottom: 0; - } - - .name-container { - display: flex; - align-items: center; - } - - .name { - font-size: $large; - font-weight: $bold; - } -} diff --git a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/Vulnerabilities.tests.tsx b/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/Vulnerabilities.tests.tsx deleted file mode 100644 index bd98579dfe..0000000000 --- a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/Vulnerabilities.tests.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from "react"; -import { render, screen } from "@testing-library/react"; - -import createMockSoftware from "__mocks__/softwareMock"; - -import Vulnerabilities from "./Vulnerabilities"; - -describe("Vulnerabilities", () => { - const [mockSoftwareWithVuln, mockSoftwareNoVulns] = [ - createMockSoftware({ - vulnerabilities: [ - { - cve: "CVE_333", - details_link: "https://its.really.bad", - cvss_score: 9.5, - epss_probability: 1, - cisa_known_exploit: false, - cve_published: "2023-02-14T20:15:00Z", - }, - ], - }), - createMockSoftware(), - ]; - - it("renders the empty state when no vulnerabilities are provided", () => { - render( - - ); - - // Empty state - expect( - screen.getByText("No vulnerabilities detected for this software item.") - ).toBeInTheDocument(); - expect( - screen.getByText("Expecting to see vulnerabilities?") - ).toBeInTheDocument(); - expect(screen.getByText("File an issue on GitHub")).toBeInTheDocument(); - }); - - it("correctly renders a table when 1 vulnerability is provided, Premium tier", () => { - render( - - ); - - // Rendered table - expect(screen.getByText("Vulnerability")).toBeInTheDocument(); - expect(screen.getByText("Probability of exploit")).toBeInTheDocument(); - expect(screen.getByText("Severity")).toBeInTheDocument(); - expect(screen.getByText("Known exploit")).toBeInTheDocument(); - expect(screen.getByText("Published")).toBeInTheDocument(); - expect(screen.getByText("CVE_333")).toBeInTheDocument(); - expect(screen.getByText("100%")).toBeInTheDocument(); - expect(screen.getByText("Critical", { exact: false })).toBeInTheDocument(); - expect(screen.getByText("ago", { exact: false })).toBeInTheDocument(); - }); - - it("Only renders the 'Vulnerability' column when 1 vulnerability is provided on Free tier", () => { - render( - - ); - - // Rendered table - expect(screen.getByText("Vulnerability")).toBeInTheDocument(); - - // No premium-only columns - expect(screen.queryByText("Probability of exploit")).toBeNull(); - expect(screen.queryByText("Severity")).toBeNull(); - expect(screen.queryByText("Known exploit")).toBeNull(); - expect(screen.queryByText("Published")).toBeNull(); - - // Row data - expect(screen.getByText("CVE_333")).toBeInTheDocument(); - expect(screen.queryByText("100%")).toBeNull(); - expect(screen.queryByText("Critical", { exact: false })).toBeNull(); - expect(screen.queryByText("ago", { exact: false })).toBeNull(); - }); - - // Test for premium icons on column headers in Sandbox mode - it("Renders 4 'Premium feature' tooltips when in premium tier Sandbox mode", () => { - render( - - ); - - expect( - screen.getAllByText("This is a Fleet Premium feature.", { exact: false }) - ).toHaveLength(4); - }); -}); diff --git a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/Vulnerabilities.tsx b/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/Vulnerabilities.tsx deleted file mode 100644 index 0446c6f20d..0000000000 --- a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/Vulnerabilities.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React, { useMemo } from "react"; - -import { ISoftware } from "interfaces/software"; -import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants"; - -import TableContainer from "components/TableContainer"; -import CustomLink from "components/CustomLink"; -import EmptyTable from "components/EmptyTable"; - -import generateVulnTableHeaders from "./VulnTableConfig"; - -const baseClass = "vulnerabilities"; - -interface IVulnerabilitiesProps { - isLoading: boolean; - isPremiumTier: boolean; - isSandboxMode?: boolean; - software: ISoftware; -} - -const NoVulnsDetected = (): JSX.Element => { - return ( - - Expecting to see vulnerabilities?{" "} - - - } - /> - ); -}; - -const Vulnerabilities = ({ - isLoading, - isPremiumTier, - isSandboxMode = false, - software, -}: IVulnerabilitiesProps): JSX.Element => { - const tableHeaders = useMemo( - () => generateVulnTableHeaders(isPremiumTier, isSandboxMode), - [isPremiumTier, isSandboxMode] - ); - - return ( -
-

Vulnerabilities

- {software?.vulnerabilities?.length ? ( - <> - {software && ( -
- -
- )} - - ) : ( - - )} -
- ); -}; -export default Vulnerabilities; diff --git a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/_styles.scss b/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/_styles.scss deleted file mode 100644 index a42e892e85..0000000000 --- a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/_styles.scss +++ /dev/null @@ -1,60 +0,0 @@ -.software-details-page { - &__hosts-link { - display: flex; - align-items: center; - padding-bottom: $pad-small; - height: 20px; - font-size: $x-small; - color: $core-vibrant-blue; - font-weight: $bold; - text-decoration: none; - } - - #right-chevron { - width: 16px; - height: 16px; - margin-left: $pad-small; - } - - .section--vulnerabilities { - .component__tooltip-wrapper__tip-text { - max-width: $col-md; - white-space: normal; - } - - .data-table-block { - .data-table__table { - thead { - .cve__header { - width: $col-md; - } - .epss_probability__header { - width: $col-sm; - } - .cvss_score__header { - width: $col-sm; - } - - @media (max-width: $tooltip-break-md) { - .cisa_known_exploit__header { - .component__tooltip-wrapper__tip-text { - max-width: 200px; // Prevents horizontal scrolling off viewport - white-space: normal; - } - } - } - } - - tr { - .text-link { - img { - height: 12px; - width: auto; - padding-left: $pad-small; - } - } - } - } - } - } -} diff --git a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/index.ts b/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/index.ts deleted file mode 100644 index 184d8bd8da..0000000000 --- a/frontend/pages/software/SoftwareDetailsPage/components/Vulnerabilities/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./Vulnerabilities"; diff --git a/frontend/pages/software/SoftwareDetailsPage/index.ts b/frontend/pages/software/SoftwareDetailsPage/index.ts deleted file mode 100644 index 0de53678fa..0000000000 --- a/frontend/pages/software/SoftwareDetailsPage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./SoftwareDetailsPage"; diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx index 645c3616d2..5d947a7fb4 100644 --- a/frontend/router/index.tsx +++ b/frontend/router/index.tsx @@ -29,7 +29,6 @@ import LabelPage from "pages/LabelPage"; import LoginPage, { LoginPreviewPage } from "pages/LoginPage"; import LogoutPage from "pages/LogoutPage"; import ManageHostsPage from "pages/hosts/ManageHostsPage"; -import ManageSoftwarePage from "pages/software/ManageSoftwarePage"; import ManageQueriesPage from "pages/queries/ManageQueriesPage"; import ManagePacksPage from "pages/packs/ManagePacksPage"; import ManagePoliciesPage from "pages/policies/ManagePoliciesPage"; @@ -43,7 +42,6 @@ import RegistrationPage from "pages/RegistrationPage"; import ResetPasswordPage from "pages/ResetPasswordPage"; import MDMAppleSSOPage from "pages/MDMAppleSSOPage"; import MDMAppleSSOCallbackPage from "pages/MDMAppleSSOCallbackPage"; -import SoftwareDetailsPage from "pages/software/SoftwareDetailsPage"; import ApiOnlyUser from "pages/ApiOnlyUser"; import Fleet403 from "pages/errors/Fleet403"; import Fleet404 from "pages/errors/Fleet404"; @@ -59,6 +57,11 @@ import WindowsMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/Windo import MacOSMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/MacOSMdmPage"; import Scripts from "pages/ManageControlsPage/Scripts/Scripts"; import WindowsAutomaticEnrollmentPage from "pages/admin/IntegrationsPage/cards/AutomaticEnrollment/WindowsAutomaticEnrollmentPage"; +import SoftwarePage from "pages/SoftwarePage"; +import SoftwareTitles from "pages/SoftwarePage/SoftwareTitles"; +import SoftwareVersions from "pages/SoftwarePage/SoftwareVersions"; +import SoftwareTitleDetailsPage from "pages/SoftwarePage/SoftwareTitleDetailsPage"; +import SoftwareVersionDetailsPage from "pages/SoftwarePage/SoftwareVersionDetailsPage"; import PATHS from "router/paths"; @@ -206,9 +209,13 @@ const routes = ( - - - + + + + + + + diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 6babce6bba..217a951694 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -43,6 +43,16 @@ export default { ADMIN_ORGANIZATION_ADVANCED: `${URL_PREFIX}/settings/organization/advanced`, ADMIN_ORGANIZATION_FLEET_DESKTOP: `${URL_PREFIX}/settings/organization/fleet-desktop`, + // Software pages + SOFTWARE_TITLES: `${URL_PREFIX}/software/titles`, + SOFTWARE_VERSIONS: `${URL_PREFIX}/software/versions`, + SOFTWARE_TITLE_DETAILS: (id: string): string => { + return `${URL_PREFIX}/software/titles/${id}`; + }, + SOFTWARE_VERSION_DETAILS: (id: string): string => { + return `${URL_PREFIX}/software/versions/${id}`; + }, + EDIT_PACK: (packId: number): string => { return `${URL_PREFIX}/packs/${packId}/edit`; }, @@ -107,10 +117,7 @@ export default { DEVICE_USER_DETAILS_POLICIES: (deviceAuthToken: string): string => { return `${URL_PREFIX}/device/${deviceAuthToken}/policies`; }, - MANAGE_SOFTWARE: `${URL_PREFIX}/software/manage`, - SOFTWARE_DETAILS: (id: string): string => { - return `${URL_PREFIX}/software/${id}`; - }, + TEAM_DETAILS_MEMBERS: (teamId?: number): string => { if (teamId !== undefined && teamId > 0) { return `${URL_PREFIX}/settings/teams/members?team_id=${teamId}`; diff --git a/frontend/services/entities/host_count.ts b/frontend/services/entities/host_count.ts index 7c30d17b35..fb04978c07 100644 --- a/frontend/services/entities/host_count.ts +++ b/frontend/services/entities/host_count.ts @@ -40,6 +40,8 @@ export interface IHostCountLoadOptions { policyResponse?: string; macSettingsStatus?: MacSettingsStatusQueryParam; softwareId?: number; + softwareTitleId?: number; + softwareVersionId?: number; lowDiskSpaceHosts?: number; mdmId?: number; mdmEnrollmentStatus?: string; @@ -62,6 +64,8 @@ export default { const globalFilter = options?.globalFilter || ""; const teamId = options?.teamId; const softwareId = options?.softwareId; + const softwareTitleId = options?.softwareTitleId; + const softwareVersionId = options?.softwareVersionId; const macSettingsStatus = options?.macSettingsStatus; const status = options?.status; const mdmId = options?.mdmId; @@ -91,6 +95,8 @@ export default { mdmEnrollmentStatus, munkiIssueId, softwareId, + softwareTitleId, + softwareVersionId, lowDiskSpaceHosts, osName, osId, diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index 65f8b1b241..f9ebde9697 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -9,7 +9,7 @@ import { reconcileMutuallyInclusiveHostParams, } from "utilities/url"; import { SelectedPlatform } from "interfaces/platform"; -import { ISoftware } from "interfaces/software"; +import { ISoftwareTitle, ISoftware } from "interfaces/software"; import { DiskEncryptionStatus, BootstrapPackageStatus, @@ -25,7 +25,8 @@ export interface ISortOption { export interface ILoadHostsResponse { hosts: IHost[]; - software: ISoftware; + software: ISoftware | undefined; + software_title: ISoftwareTitle | undefined; munki_issue: IMunkiIssuesAggregate; mobile_device_management_solution: IMdmSolution; } @@ -55,6 +56,8 @@ export interface ILoadHostsOptions { policyResponse?: string; macSettingsStatus?: MacSettingsStatusQueryParam; softwareId?: number; + softwareTitleId?: number; + softwareVersionId?: number; status?: HostStatus; mdmId?: number; mdmEnrollmentStatus?: string; @@ -82,6 +85,8 @@ export interface IExportHostsOptions { policyResponse?: string; macSettingsStatus?: MacSettingsStatusQueryParam; softwareId?: number; + softwareTitleId?: number; + softwareVersionId?: number; status?: HostStatus; mdmId?: number; munkiIssueId?: number; @@ -177,6 +182,8 @@ export default { const policyId = options?.policyId; const policyResponse = options?.policyResponse || "passing"; const softwareId = options?.softwareId; + const softwareTitleId = options?.softwareTitleId; + const softwareVersionId = options?.softwareVersionId; const macSettingsStatus = options?.macSettingsStatus; const status = options?.status; const mdmId = options?.mdmId; @@ -209,6 +216,8 @@ export default { mdmEnrollmentStatus, munkiIssueId, softwareId, + softwareTitleId, + softwareVersionId, lowDiskSpaceHosts, osSettings, diskEncryptionStatus, @@ -234,6 +243,8 @@ export default { policyResponse = "passing", macSettingsStatus, softwareId, + softwareTitleId, + softwareVersionId, status, mdmId, mdmEnrollmentStatus, @@ -273,6 +284,8 @@ export default { mdmEnrollmentStatus, munkiIssueId, softwareId, + softwareTitleId, + softwareVersionId, lowDiskSpaceHosts, osId, osName, diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index 54f829171d..66bfd74161 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -6,10 +6,12 @@ import { ISoftwareResponse, ISoftwareCountResponse, IGetSoftwareByIdResponse, + ISoftwareVersion, + ISoftwareTitle, } from "interfaces/software"; import { buildQueryStringFromParams, QueryParams } from "utilities/url"; -interface ISoftwareApiParams { +export interface ISoftwareApiParams { page?: number; perPage?: number; orderKey?: string; @@ -19,6 +21,34 @@ interface ISoftwareApiParams { teamId?: number; } +export interface ISoftwareTitlesResponse { + counts_updated_at: string | null; + count: number; + software_titles: ISoftwareTitle[]; + meta: { + has_next_results: boolean; + has_previous_results: boolean; + }; +} + +export interface ISoftwareVersionsResponse { + counts_updated_at: string | null; + count: number; + software: ISoftwareVersion[]; + meta: { + has_next_results: boolean; + has_previous_results: boolean; + }; +} + +export interface ISoftwareTitleResponse { + software_title: ISoftwareTitle; +} + +export interface ISoftwareVersionResponse { + software: ISoftwareVersion; +} + export interface ISoftwareQueryKey extends ISoftwareApiParams { scope: "software"; } @@ -103,4 +133,30 @@ export default { return sendRequest("GET", path); }, + + getSoftwareTitles: (params: ISoftwareApiParams) => { + const { SOFTWARE_TITLES } = endpoints; + const snakeCaseParams = convertParamsToSnakeCase(params); + const queryString = buildQueryStringFromParams(snakeCaseParams); + const path = `${SOFTWARE_TITLES}?${queryString}`; + return sendRequest("GET", path); + }, + + getSoftwareTitle: (id: number) => { + const { SOFTWARE_TITLE } = endpoints; + return sendRequest("GET", SOFTWARE_TITLE(id)); + }, + + getSoftwareVersions: (params: ISoftwareApiParams) => { + const { SOFTWARE_VERSIONS } = endpoints; + const snakeCaseParams = convertParamsToSnakeCase(params); + const queryString = buildQueryStringFromParams(snakeCaseParams); + const path = `${SOFTWARE_VERSIONS}?${queryString}`; + return sendRequest("GET", path); + }, + + getSoftwareVersion: (id: number) => { + const { SOFTWARE_VERSION } = endpoints; + return sendRequest("GET", SOFTWARE_VERSION(id)); + }, }; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index f93487b2a2..545ed0d666 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -91,7 +91,15 @@ export default { return `/${API_VERSION}/fleet/packs/${packId}/scheduled`; }, SETUP: `/v1/setup`, // not a typo - hasn't been updated yet + + // Software endpoints SOFTWARE: `/${API_VERSION}/fleet/software`, + SOFTWARE_TITLES: `/${API_VERSION}/fleet/software/titles`, + SOFTWARE_TITLE: (id: number) => `/${API_VERSION}/fleet/software/titles/${id}`, + SOFTWARE_VERSIONS: `/${API_VERSION}/fleet/software/versions`, + SOFTWARE_VERSION: (id: number) => + `/${API_VERSION}/fleet/software/versions/${id}`, + SSO: `/v1/fleet/sso`, STATUS_LABEL_COUNTS: `/${API_VERSION}/fleet/host_summary`, STATUS_LIVE_QUERY: `/${API_VERSION}/fleet/status/live_query`, @@ -126,7 +134,7 @@ export default { USERS_ADMIN: `/${API_VERSION}/fleet/users/admin`, VERSION: `/${API_VERSION}/fleet/version`, - // SCRIPTS + // Script endpoints HOST_SCRIPTS: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/scripts`, SCRIPTS: `/${API_VERSION}/fleet/scripts`, SCRIPT: (id: number) => `/${API_VERSION}/fleet/scripts/${id}`, diff --git a/frontend/utilities/url/index.ts b/frontend/utilities/url/index.ts index 5fcf792bbc..0f4c9a408f 100644 --- a/frontend/utilities/url/index.ts +++ b/frontend/utilities/url/index.ts @@ -30,6 +30,8 @@ interface IMutuallyExclusiveHostParams { munkiIssueId?: number; lowDiskSpaceHosts?: number; softwareId?: number; + softwareVersionId?: number; + softwareTitleId?: number; osId?: number; osName?: string; osVersion?: string; @@ -100,6 +102,8 @@ export const reconcileMutuallyExclusiveHostParams = ({ munkiIssueId, lowDiskSpaceHosts, softwareId, + softwareVersionId, + softwareTitleId, osId, osName, osVersion, @@ -131,6 +135,10 @@ export const reconcileMutuallyExclusiveHostParams = ({ return { mdm_enrollment_status: mdmEnrollmentStatus }; case !!munkiIssueId: return { munki_issue_id: munkiIssueId }; + case !!softwareTitleId: + return { software_title_id: softwareTitleId }; + case !!softwareVersionId: + return { software_version_id: softwareVersionId }; case !!softwareId: return { software_id: softwareId }; case !!osId: @@ -141,7 +149,6 @@ export const reconcileMutuallyExclusiveHostParams = ({ return { low_disk_space: lowDiskSpaceHosts }; case !!osSettings: return { [HOSTS_QUERY_PARAMS.OS_SETTINGS]: osSettings }; - case !!diskEncryptionStatus: return { [HOSTS_QUERY_PARAMS.DISK_ENCRYPTION]: diskEncryptionStatus }; case !!bootstrapPackageStatus: diff --git a/server/fleet/software.go b/server/fleet/software.go index 089ea808f6..da30d346e6 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -120,7 +120,7 @@ type SoftwareVersion struct { // Version is the version string we grab for this specific software. Version string `db:"version" json:"version"` // Vulnerabilities is the list of CVE names for vulnerabilities found for this version. - Vulnerabilities *SliceString `db:"vulnerabilities" json:"vulnerabilities,omitempty"` + Vulnerabilities *SliceString `db:"vulnerabilities" json:"vulnerabilities"` // HostsCount is the number of hosts that use this software version. HostsCount *uint `db:"hosts_count" json:"hosts_count,omitempty"`