diff --git a/frontend/components/TabNav/_styles.scss b/frontend/components/TabNav/_styles.scss index 180334ea03..fb3a791f93 100644 --- a/frontend/components/TabNav/_styles.scss +++ b/frontend/components/TabNav/_styles.scss @@ -72,14 +72,15 @@ } &--disabled { - cursor: not-allowed; + &:hover { + background-color: transparent; + } } } &__tab-panel--selected { margin-top: $gap-page-component; animation: fade-in 250ms ease-out; - } .tab-text { diff --git a/frontend/components/TableContainer/TableContainer.tsx b/frontend/components/TableContainer/TableContainer.tsx index dd9a5e8449..d75501c416 100644 --- a/frontend/components/TableContainer/TableContainer.tsx +++ b/frontend/components/TableContainer/TableContainer.tsx @@ -94,8 +94,6 @@ interface ITableContainerProps { | ((queryData: ITableQueryData) => void) | ((queryData: ITableQueryData) => number); customControl?: () => JSX.Element | null; - /** Filter button right of the search rendering alternative responsive design where search bar moves to new line but filter button remains inline with other table headers */ - customFiltersButton?: () => JSX.Element; stackControls?: boolean; onSelectSingleRow?: (value: Row | IRowProps) => void; /** This is called when you click on a row. This was added as `onSelectSingleRow` @@ -173,7 +171,6 @@ const TableContainer = ({ hideFooter, onQueryChange, customControl, - customFiltersButton, stackControls, onSelectSingleRow, onClickRow, @@ -409,7 +406,6 @@ const TableContainer = ({ )} - {customFiltersButton && customFiltersButton()} ); @@ -491,7 +487,6 @@ const TableContainer = ({ }, [ actionButton, customControl, - customFiltersButton, disableActionButton, disableCount, disableTableHeader, diff --git a/frontend/components/TableContainer/_styles.scss b/frontend/components/TableContainer/_styles.scss index ea00bd55e2..7aeb64eb18 100644 --- a/frontend/components/TableContainer/_styles.scss +++ b/frontend/components/TableContainer/_styles.scss @@ -3,7 +3,7 @@ flex-direction: column; gap: $gap-table-elements; - // Container is responsive design used when customFilters is rendered + // Container is responsive design used when customControl is rendered .container { display: grid; grid-template-columns: 1fr auto; /* First column takes all remaining space */ diff --git a/frontend/components/top_nav/SiteTopNav/navItems.ts b/frontend/components/top_nav/SiteTopNav/navItems.ts index 08719c6764..56d638d8ec 100644 --- a/frontend/components/top_nav/SiteTopNav/navItems.ts +++ b/frontend/components/top_nav/SiteTopNav/navItems.ts @@ -12,8 +12,8 @@ export interface INavItem { }; exclude?: boolean; /** If `true`, this nav item will always navigate to the given `location.pathname`. This - * is useful when you want to always naviate to a specific path no matter - * which child page you are on (e.g. always navigate to /sofware/titles/ when + * is useful when you want to always navigate to a specific path no matter + * which child page you are on (e.g. always navigate to /software/inventory/ when * clicking on the software nav item even if on /software/versions, * software/titles/:id, or /software/versions/:id). Defaults to `undefined`. */ @@ -77,7 +77,7 @@ export default ( name: "Software", location: { regex: new RegExp(`^${URL_PREFIX}/software/`), - pathname: PATHS.SOFTWARE_TITLES, + pathname: PATHS.SOFTWARE_INVENTORY, }, alwaysToPathname: true, withParams: { type: "query", names: ["fleet_id"] }, diff --git a/frontend/pages/DashboardPage/DashboardPage.tsx b/frontend/pages/DashboardPage/DashboardPage.tsx index 8169999f8c..b0b23740e5 100644 --- a/frontend/pages/DashboardPage/DashboardPage.tsx +++ b/frontend/pages/DashboardPage/DashboardPage.tsx @@ -511,11 +511,13 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { }; const onSoftwareTabChange = (index: number) => { - const { SOFTWARE_TITLES } = paths; + const { SOFTWARE_INVENTORY } = paths; setSoftwareNavTabIndex(index); setSoftwareActionUrl && setSoftwareActionUrl( - index === 1 ? `${SOFTWARE_TITLES}?vulnerable=true` : SOFTWARE_TITLES + index === 1 + ? `${SOFTWARE_INVENTORY}?vulnerable=true` + : SOFTWARE_INVENTORY ); setSoftwarePageIndex(0); }; diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAddPage.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAddPage.tsx index a07f8125e0..e2955edf2c 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAddPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAddPage.tsx @@ -106,7 +106,7 @@ const SoftwareAddPage = ({ setSelectedOsqueryTable(tableName); }; - const backUrl = getPathWithQueryParams(PATHS.SOFTWARE_TITLES, { + const backUrl = getPathWithQueryParams(PATHS.SOFTWARE_LIBRARY, { fleet_id: location.query.fleet_id, }); diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStore/SoftwareAppStoreAndroid/SoftwareAppStoreAndroid.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStore/SoftwareAppStoreAndroid/SoftwareAppStoreAndroid.tsx index 4fc7b86e8e..927772aabe 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStore/SoftwareAppStoreAndroid/SoftwareAppStoreAndroid.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStore/SoftwareAppStoreAndroid/SoftwareAppStoreAndroid.tsx @@ -29,13 +29,12 @@ const SoftwareAppStoreAndroid = ({ const [isLoading, setIsLoading] = useState(false); - const goBackToSoftwareTitles = (showAvailableForInstallOnly = false) => { - const queryParams = { - fleet_id: currentTeamId, - ...(showAvailableForInstallOnly && { available_for_install: true }), - }; - - router.push(getPathWithQueryParams(PATHS.SOFTWARE_TITLES, queryParams)); + const goBackToSoftwareLibrary = () => { + router.push( + getPathWithQueryParams(PATHS.SOFTWARE_LIBRARY, { + fleet_id: currentTeamId, + }) + ); }; const onAddSoftware = async (formData: ISoftwareAndroidFormData) => { @@ -84,7 +83,7 @@ const SoftwareAppStoreAndroid = ({
diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStore/SoftwareAppStoreVpp/SoftwareAppStoreVpp.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStore/SoftwareAppStoreVpp/SoftwareAppStoreVpp.tsx index da9ff486dd..ca7fa42743 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStore/SoftwareAppStoreVpp/SoftwareAppStoreVpp.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStore/SoftwareAppStoreVpp/SoftwareAppStoreVpp.tsx @@ -148,13 +148,12 @@ const SoftwareAppStoreVpp = ({ } ); - const goBackToSoftwareTitles = (showAvailableForInstallOnly = false) => { - const queryParams = { - fleet_id: currentTeamId, - ...(showAvailableForInstallOnly && { available_for_install: true }), - }; - - router.push(getPathWithQueryParams(PATHS.SOFTWARE_TITLES, queryParams)); + const goBackToSoftwareLibrary = () => { + router.push( + getPathWithQueryParams(PATHS.SOFTWARE_LIBRARY, { + fleet_id: currentTeamId, + }) + ); }; const onClickPreviewEndUserExperience = (iosOrIpadosApp?: boolean) => { @@ -185,6 +184,9 @@ const SoftwareAppStoreVpp = ({ queryClient.invalidateQueries({ queryKey: [{ scope: "software-titles" }], }); + queryClient.invalidateQueries({ + queryKey: [{ scope: "software-library" }], + }); queryClient.invalidateQueries({ queryKey: ["vppSoftware", currentTeamId], }); @@ -241,7 +243,7 @@ const SoftwareAppStoreVpp = ({ { router.push( - getPathWithQueryParams(PATHS.SOFTWARE_TITLES, { + getPathWithQueryParams(PATHS.SOFTWARE_LIBRARY, { fleet_id: currentTeamId, }) ); @@ -149,6 +149,9 @@ const SoftwareCustomPackage = ({ queryClient.invalidateQueries({ queryKey: [{ scope: "software-titles" }], }); + queryClient.invalidateQueries({ + queryKey: [{ scope: "software-library" }], + }); const newQueryParams: QueryParams = { fleet_id: currentTeamId, diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx index fbccf4e1a9..27d586f358 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx @@ -189,6 +189,9 @@ const FleetMaintainedAppDetailsPage = ({ queryClient.invalidateQueries({ queryKey: [{ scope: "software-titles" }], }); + queryClient.invalidateQueries({ + queryKey: [{ scope: "software-library" }], + }); queryClient.invalidateQueries({ queryKey: [{ scope: "fleet-maintained-apps" }], }); diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx b/frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventory.tsx similarity index 89% rename from frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx rename to frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventory.tsx index 130cb24cae..8e2949e608 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx +++ b/frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventory.tsx @@ -1,5 +1,5 @@ /** - software/titles Software tab + software/inventory Software tab software/versions Software tab (version toggle on) */ import React from "react"; @@ -18,14 +18,10 @@ import softwareAPI, { import Spinner from "components/Spinner"; import TableDataError from "components/DataError"; -import SoftwareTable from "./SoftwareTable"; -import { - ISoftwareDropdownFilterVal, - ISoftwareVulnFilters, - buildSoftwareFilterQueryParams, -} from "./SoftwareTable/helpers"; +import SoftwareInventoryTable from "./SoftwareInventoryTable"; +import { ISoftwareVulnFilters } from "./SoftwareInventoryTable/helpers"; -const baseClass = "software-titles"; +const baseClass = "software-inventory"; const DATA_STALE_TIME = 30000; const QUERY_OPTIONS = { @@ -33,33 +29,31 @@ const QUERY_OPTIONS = { staleTime: DATA_STALE_TIME, }; -interface ISoftwareTitlesProps { +interface ISoftwareInventoryProps { router: InjectedRouter; isSoftwareEnabled: boolean; query: string; perPage: number; orderDirection: "asc" | "desc"; orderKey: string; - softwareFilter: ISoftwareDropdownFilterVal; vulnFilters: ISoftwareVulnFilters; currentPage: number; teamId?: number; onAddFiltersClick: () => void; } -const SoftwareTitles = ({ +const SoftwareInventory = ({ router, isSoftwareEnabled, query, perPage, orderDirection, orderKey, - softwareFilter, vulnFilters, currentPage, teamId, onAddFiltersClick, -}: ISoftwareTitlesProps) => { +}: ISoftwareInventoryProps) => { const showVersions = location.pathname === PATHS.SOFTWARE_VERSIONS; // for Titles view, request to get software data @@ -84,14 +78,13 @@ const SoftwareTitles = ({ orderKey, teamId, ...vulnFilters, - ...buildSoftwareFilterQueryParams(softwareFilter), }, ], ({ queryKey: [queryKey] }) => softwareAPI.getSoftwareTitles(omit(queryKey, "scope")), { ...QUERY_OPTIONS, - enabled: location.pathname === PATHS.SOFTWARE_TITLES, + enabled: location.pathname === PATHS.SOFTWARE_INVENTORY, } ); @@ -186,7 +179,7 @@ const SoftwareTitles = ({ return (
- { +describe("Software inventory table", () => { it("Renders the page-wide disabled state when software inventory is disabled", () => { const render = createCustomRenderer({ context: { @@ -25,7 +25,7 @@ describe("Software table", () => { }); render( - { perPage={20} orderDirection="asc" orderKey="hosts_count" - softwareFilter="allSoftware" vulnFilters={{ vulnerable: false, exploit: false, @@ -58,7 +57,7 @@ describe("Software table", () => { expect(screen.queryByText("Available for install")).toBeNull(); }); - it("Renders the page-wide empty state when no software are present hiding 'Available for install' filter", () => { + it("Renders the page-wide empty state when no software are present hiding search bar and vulnerability filtering", () => { const render = createCustomRenderer({ context: { app: { @@ -69,7 +68,7 @@ describe("Software table", () => { }); render( - { perPage={20} orderDirection="asc" orderKey="hosts_count" - softwareFilter="allSoftware" vulnFilters={{ vulnerable: false, exploit: false, @@ -104,11 +102,10 @@ describe("Software table", () => { expect(screen.getByText("0 items")).toBeInTheDocument(); expect(screen.queryByText("Search")).toBeNull(); expect(screen.queryByText("Updated")).toBeNull(); - expect(screen.queryByText("All software")).toBeNull(); - expect(screen.queryByText("Available for install")).toBeNull(); + expect(screen.queryByText("Add filters")).toBeNull(); }); - it("Renders the page-wide empty state hiding 'Available for install' filter when search query does not exist but versions toggle is applied", () => { + it("Renders the page-wide empty state hiding vulnerability filtering when search query does not exist but versions toggle is applied", () => { const render = createCustomRenderer({ context: { app: { @@ -119,7 +116,7 @@ describe("Software table", () => { }); render( - { perPage={20} orderDirection="asc" orderKey="hosts_count" - softwareFilter="allSoftware" vulnFilters={{ vulnerable: false, exploit: false, @@ -150,11 +146,11 @@ describe("Software table", () => { expect( screen.getByText("Expecting to see software? Check back later.") ).toBeInTheDocument(); - expect(screen.queryByText("All software")).toBeNull(); - expect(screen.queryByText("Available for install")).toBeNull(); + expect(screen.queryByText("Search")).toBeNull(); + expect(screen.queryByText("Add filters")).toBeNull(); }); - it("Renders the empty search state and 'Available for install' filter when search query does not exist but filter is applied", () => { + it("Renders the empty search state and vulnerability filtering when search query does not exist but vulnerability filter is applied", () => { const render = createCustomRenderer({ context: { app: { @@ -165,7 +161,7 @@ describe("Software table", () => { }); render( - { perPage={20} orderDirection="asc" orderKey="hosts_count" - softwareFilter="installableSoftware" // Dropdown applied - vulnFilters={{ - vulnerable: false, - exploit: false, - minCvssScore: undefined, - maxCvssScore: undefined, - }} - currentPage={0} - teamId={1} - isLoading={false} - onAddFiltersClick={noop} - /> - ); - - expect( - screen.getByText("No items match the current search criteria") - ).toBeInTheDocument(); - expect( - screen.getByText( - "Expecting to see installable software? Check back later." - ) - ).toBeInTheDocument(); - expect(screen.getByText("Available for install")).toBeInTheDocument(); - }); - - it("Renders the empty search state and 'Available for install' filter when search query does not exist but vulnerability filter is applied", () => { - const render = createCustomRenderer({ - context: { - app: { - isGlobalAdmin: true, - currentUser: createMockUser(), - }, - }, - }); - - render( - { "Expecting to see vulnerable software? Check back later." ) ).toBeInTheDocument(); - expect(screen.getByText("All software")).toBeInTheDocument(); - }); - - it("does not render 'Available for install' filter when team id is undefined (Fleet Free/All teams)", () => { - const render = createCustomRenderer({ - context: { - app: { - isGlobalAdmin: true, - currentUser: createMockUser(), - }, - }, - }); - - render( - - ); - - expect(screen.queryByText("All software")).toBeNull(); - expect(screen.queryByText("Available for install")).toBeNull(); + expect( + screen.getByPlaceholderText("Search by name or vulnerability (CVE)") + ).toBeInTheDocument(); + expect(screen.getByText("1 filter")).toBeInTheDocument(); }); }); diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx b/frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/SoftwareInventoryTable.tsx similarity index 73% rename from frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx rename to frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/SoftwareInventoryTable.tsx index 513fa688ee..b472fa8967 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/SoftwareInventoryTable.tsx @@ -10,12 +10,8 @@ import { Row } from "react-table"; import PATHS from "router/paths"; import { getNextLocationPath } from "utilities/helpers"; import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants"; +import { getPathWithQueryParams } from "utilities/url"; import { - convertParamsToSnakeCase, - getPathWithQueryParams, -} from "utilities/url"; -import { - ISoftwareApiParams, ISoftwareTitlesResponse, ISoftwareVersionsResponse, } from "services/entities/software"; @@ -30,19 +26,13 @@ import TableCount from "components/TableContainer/TableCount"; import Button from "components/buttons/Button"; import Icon from "components/Icon"; import TooltipWrapper from "components/TooltipWrapper"; -import { SingleValue } from "react-select-5"; -import DropdownWrapper from "components/forms/fields/DropdownWrapper"; -import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper"; import EmptySoftwareTable from "pages/SoftwarePage/components/tables/EmptySoftwareTable"; -import generateTitlesTableConfig from "./SoftwareTitlesTableConfig"; +import generateInventoryTableConfig from "./SoftwareInventoryTableConfig"; import generateVersionsTableConfig from "./SoftwareVersionsTableConfig"; import { - ISoftwareDropdownFilterVal, ISoftwareVulnFiltersParams, - SOFTWARE_TITLES_DROPDOWN_OPTIONS, - buildSoftwareFilterQueryParams, buildSoftwareVulnFiltersQueryParams, getVulnFilterRenderDetails, } from "./helpers"; @@ -72,7 +62,6 @@ interface ISoftwareTableProps { perPage: number; orderDirection: "asc" | "desc"; orderKey: string; - softwareFilter: ISoftwareDropdownFilterVal; vulnFilters: ISoftwareVulnFiltersParams; currentPage: number; teamId?: number; @@ -80,7 +69,7 @@ interface ISoftwareTableProps { onAddFiltersClick: () => void; } -const baseClass = "software-table"; +const baseClass = "software-inventory-table"; const SoftwareTable = ({ router, @@ -92,7 +81,6 @@ const SoftwareTable = ({ perPage, orderDirection, orderKey, - softwareFilter, vulnFilters, currentPage, teamId, @@ -101,7 +89,7 @@ const SoftwareTable = ({ }: ISoftwareTableProps) => { const currentPath = showVersions ? PATHS.SOFTWARE_VERSIONS - : PATHS.SOFTWARE_TITLES; + : PATHS.SOFTWARE_INVENTORY; const determineQueryParamChange = useCallback( (newTableQuery: ITableQueryData) => { @@ -137,19 +125,10 @@ const SoftwareTable = ({ : 0, ...buildSoftwareVulnFiltersQueryParams(vulnFilters), }; - // Only include these filters when not on “All teams” - if (teamId !== undefined) { - if (softwareFilter === "installableSoftware") { - newQueryParam.available_for_install = "true"; - } - if (softwareFilter === "selfServiceSoftware") { - newQueryParam.self_service = "true"; - } - } return newQueryParam; }, - [softwareFilter, teamId, vulnFilters] + [teamId, vulnFilters] ); // NOTE: this is called once on initial render and every time the query changes @@ -181,7 +160,7 @@ const SoftwareTable = ({ generateTableConfig = () => []; } else if (isSoftwareTitles(data)) { tableData = data.software_titles; - generateTableConfig = generateTitlesTableConfig; + generateTableConfig = generateInventoryTableConfig; } else { tableData = data.software; generateTableConfig = generateVersionsTableConfig; @@ -195,13 +174,11 @@ const SoftwareTable = ({ // Determines if a user should be able to filter or search in the table const hasData = tableData && tableData.length > 0; const hasQuery = query !== ""; - const hasSoftwareFilter = softwareFilter !== "allSoftware"; const vulnFilterDetails = getVulnFilterRenderDetails(vulnFilters); const hasVulnFilters = vulnFilterDetails.filterCount > 0; const showFilterHeaders = - isSoftwareEnabled && - (hasData || hasQuery || hasSoftwareFilter || hasVulnFilters); + isSoftwareEnabled && (hasData || hasQuery || hasVulnFilters); const handleShowVersionsToggle = () => { const queryParams: Record = { @@ -210,14 +187,13 @@ const SoftwareTable = ({ order_direction: orderDirection, order_key: orderKey, page: 0, // resets page index - ...buildSoftwareFilterQueryParams("allSoftware"), // Reset to all software ...buildSoftwareVulnFiltersQueryParams(vulnFilters), }; router.replace( getNextLocationPath({ pathPrefix: showVersions - ? PATHS.SOFTWARE_TITLES + ? PATHS.SOFTWARE_INVENTORY : PATHS.SOFTWARE_VERSIONS, routeTemplate: "", queryParams, @@ -225,30 +201,6 @@ const SoftwareTable = ({ ); }; - const handleCustomFilterDropdownChange = ( - value: ISoftwareDropdownFilterVal - ) => { - const queryParams: ISoftwareApiParams = { - query, - teamId, - orderDirection, - orderKey, - // Do not reset page as it creates a race condition with TableContainer's useDeepEffect - // Rely on TableContainer's prevPageIndex to reset to 0 when it detects additionalQueries changed - page: currentPage, - ...buildSoftwareVulnFiltersQueryParams(vulnFilters), - ...buildSoftwareFilterQueryParams(value), - }; - - router.replace( - getNextLocationPath({ - pathPrefix: currentPath, - routeTemplate: "", - queryParams: convertParamsToSnakeCase(queryParams), - }) - ); - }; - const handleRowSelect = (row: IRowProps) => { if (!row.original.id) return; @@ -285,32 +237,6 @@ const SoftwareTable = ({ ); }; - const renderCustomControls = () => { - // Hidden when viewing versions table or viewing "All teams" - // or Fleet Free - if (showVersions || teamId === undefined) { - return null; - } - - return ( -
- ) => - newValue && - handleCustomFilterDropdownChange( - newValue.value as ISoftwareDropdownFilterVal - ) - } - variant="table-filter" - /> -
- ); - }; - const renderCustomFiltersButton = () => { return ( ( { + const headers = generateTableHeaders(mockRouter, 1); + + it("generates the correct column headers", () => { + const headerNames = headers.map((h) => { + if (typeof h.Header === "string") return h.Header; + return h.accessor; // sortable headers use accessor as key + }); + + expect(headerNames).toEqual([ + "name", + "Version", + "Type", + "Vulnerabilities", + "hosts_count", + "", + ]); + }); + + it("has a Version column (not 'Installed version')", () => { + const versionCol = headers.find( + (h) => typeof h.Header === "string" && h.Header === "Version" + ); + expect(versionCol).toBeDefined(); + }); + + it("does not have a Library version column", () => { + const libraryVersionCol = headers.find( + (h) => typeof h.Header === "string" && h.Header === "Library version" + ); + expect(libraryVersionCol).toBeUndefined(); + }); + + it("has a Vulnerabilities column", () => { + const vulnCol = headers.find( + (h) => typeof h.Header === "string" && h.Header === "Vulnerabilities" + ); + expect(vulnCol).toBeDefined(); + }); + + it("disables sorting on Version, Type, and Vulnerabilities", () => { + const nonSortable = headers.filter((h) => h.disableSortBy === true); + const nonSortableKeys = nonSortable.map( + (h) => (typeof h.Header === "string" ? h.Header : h.id) || h.accessor + ); + + expect(nonSortableKeys).toContain("Version"); + expect(nonSortableKeys).toContain("Type"); + expect(nonSortableKeys).toContain("Vulnerabilities"); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/SoftwareInventoryTableConfig.tsx similarity index 100% rename from frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx rename to frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/SoftwareInventoryTableConfig.tsx diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareVersionsTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/SoftwareVersionsTableConfig.tsx similarity index 100% rename from frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareVersionsTableConfig.tsx rename to frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/SoftwareVersionsTableConfig.tsx diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/_styles.scss b/frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/_styles.scss similarity index 93% rename from frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/_styles.scss rename to frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/_styles.scss index 5e502ab47a..cd327ea56d 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/_styles.scss +++ b/frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/_styles.scss @@ -1,14 +1,4 @@ -.software-table { - &__software-filter { - min-width: 240px; - } - - &__filter-controls { - display: flex; - align-items: center; - gap: $pad-medium; - } - +.software-inventory-table { &__filters { .component__tooltip-wrapper__element { display: flex; diff --git a/frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/helpers.tests.ts new file mode 100644 index 0000000000..03d39438e1 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/helpers.tests.ts @@ -0,0 +1,52 @@ +import { getVulnerabilities } from "./helpers"; + +const versions = [ + { + id: 531270, + version: "131.0.6778.86", + vulnerabilities: ["CVE-2024-12053", "CVE-2024-12381", "CVE-2025-0444"], + }, + { + id: 538184, + version: "132.0.6834.160", + vulnerabilities: ["CVE-2025-0444", "CVE-2025-0445"], // 0444 is duplicate + }, + { + id: 541233, + version: "133.0.6943.53", + vulnerabilities: ["CVE-2025-0995", "CVE-2025-0996"], + }, + { + id: 572993, + version: "139.0.7258.127", + vulnerabilities: null, // should be ignored + }, +]; + +describe("getVulnerabilities", () => { + it("returns a unique list of vulnerabilities across all versions", () => { + const result = getVulnerabilities(versions); + + // Expect no duplicates + expect(new Set(result).size).toBe(result.length); + + // Expect specific vulns present + expect(result).toEqual( + expect.arrayContaining([ + "CVE-2024-12053", + "CVE-2024-12381", + "CVE-2025-0444", + "CVE-2025-0445", + "CVE-2025-0995", + "CVE-2025-0996", + ]) + ); + + // Should not contain unintended values + expect(result).not.toContain("CVE-DOES-NOT-EXIST"); + }); + + it("returns an empty array if no versions are given", () => { + expect(getVulnerabilities([])).toEqual([]); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts b/frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/helpers.ts similarity index 73% rename from frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts rename to frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/helpers.ts index e28833908e..a4fb43f75a 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts +++ b/frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/helpers.ts @@ -1,32 +1,9 @@ import { QueryParams, parseQueryValueToNumberOrUndefined } from "utilities/url"; import stringUtils from "utilities/strings/stringUtils"; import { tooltipTextWithLineBreaks } from "utilities/helpers"; +import numberUtils from "utilities/numbers"; -export type ISoftwareDropdownFilterVal = - | "allSoftware" - | "installableSoftware" - | "selfServiceSoftware"; - -export const SOFTWARE_TITLES_DROPDOWN_OPTIONS = [ - { - disabled: false, - label: "All software", - value: "allSoftware", - helpText: "All software installed on your hosts.", - }, - { - disabled: false, - label: "Available for install", - value: "installableSoftware", - helpText: "Software that can be installed on your hosts.", - }, - { - disabled: false, - label: "Self-service", - value: "selfServiceSoftware", - helpText: "Software that end users can install from Fleet Desktop.", - }, -]; +const { isValidNumber } = numberUtils; export const CUSTOM_SEVERITY_OPTION = { disabled: false, @@ -83,32 +60,6 @@ export const SEVERITY_DROPDOWN_OPTIONS = [ CUSTOM_SEVERITY_OPTION, ]; -export const buildSoftwareFilterQueryParams = ( - val: ISoftwareDropdownFilterVal -) => { - switch (val) { - case "installableSoftware": - return { availableForInstall: true }; - case "selfServiceSoftware": - return { selfService: true }; - default: - return {}; - } -}; - -// TODO: Consider parsing SoftwarePage query params to change from type string -export const getSoftwareFilterFromQueryParams = (queryParams: QueryParams) => { - const { available_for_install, self_service } = queryParams; - switch (true) { - case available_for_install === "true": - return "installableSoftware"; - case self_service === "true": - return "selfServiceSoftware"; - default: - return "allSoftware"; - } -}; - export const getSoftwareVulnFiltersFromQueryParams = ( queryParams: QueryParams ) => { @@ -136,21 +87,6 @@ export type ISoftwareVulnFiltersParams = { maxCvssScore?: number; }; -export const isValidNumber = ( - value: any, - min?: number, - max?: number -): value is number => { - // Check if the value is a number and not NaN - const isNumber = typeof value === "number" && !isNaN(value); - - // If min or max is provided, check if the number is within the range - const withinRange = - (min === undefined || value >= min) && (max === undefined || value <= max); - - return isNumber && withinRange; -}; - export const buildSoftwareVulnFiltersQueryParams = ( vulnFilters: ISoftwareVulnFiltersParams ) => { diff --git a/frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/index.ts b/frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/index.ts new file mode 100644 index 0000000000..08e82b9a6a --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwareInventoryTable"; diff --git a/frontend/pages/SoftwarePage/SoftwareInventory/_styles.scss b/frontend/pages/SoftwarePage/SoftwareInventory/_styles.scss new file mode 100644 index 0000000000..5b56a82d3a --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareInventory/_styles.scss @@ -0,0 +1,5 @@ +.software-inventory { + @include vertical-page-tab-panel-layout; + + margin-top: $gap-page-component; // Required as these Tabs don't use TabPanel +} diff --git a/frontend/pages/SoftwarePage/SoftwareInventory/index.ts b/frontend/pages/SoftwarePage/SoftwareInventory/index.ts new file mode 100644 index 0000000000..b55b108829 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareInventory/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwareInventory"; diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibrary.tsx b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibrary.tsx new file mode 100644 index 0000000000..d7eb1f6f1e --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibrary.tsx @@ -0,0 +1,114 @@ +/** + software/library Library tab — fleet-managed software available for installation + */ +import React from "react"; +import { InjectedRouter } from "react-router"; +import { useQuery } from "react-query"; +import { omit } from "lodash"; + +import PATHS from "router/paths"; +import softwareAPI, { + ISoftwareTitlesQueryKey, + ISoftwareTitlesResponse, +} from "services/entities/software"; + +import Spinner from "components/Spinner"; +import TableDataError from "components/DataError"; + +import SoftwareLibraryTable from "./SoftwareLibraryTable"; + +const baseClass = "software-library"; + +const DATA_STALE_TIME = 30000; +const QUERY_OPTIONS = { + keepPreviousData: true, + staleTime: DATA_STALE_TIME, +}; + +interface ISoftwareLibraryProps { + router: InjectedRouter; + isSoftwareEnabled: boolean; + query: string; + perPage: number; + orderDirection: "asc" | "desc"; + orderKey: string; + selfServiceOnly: boolean; + currentPage: number; + teamId?: number; +} + +const SoftwareLibrary = ({ + router, + isSoftwareEnabled, + query, + perPage, + orderDirection, + orderKey, + selfServiceOnly, + currentPage, + teamId, +}: ISoftwareLibraryProps) => { + const { + data: titlesData, + isFetching: isTitlesFetching, + isLoading: isTitlesLoading, + isError: isTitlesError, + } = useQuery< + ISoftwareTitlesResponse, + Error, + ISoftwareTitlesResponse, + [ISoftwareTitlesQueryKey] + >( + [ + { + scope: "software-library", + page: currentPage, + perPage, + query, + orderDirection, + orderKey, + teamId, + availableForInstall: true, + ...(selfServiceOnly ? { selfService: true } : {}), + }, + ], + ({ queryKey: [queryKey] }) => + softwareAPI.getSoftwareTitles(omit(queryKey, "scope")), + { + ...QUERY_OPTIONS, + // Uses window.location (not a prop) — safe because this component + // is only mounted at the /software/library route. + enabled: + teamId !== undefined && + window.location.pathname === PATHS.SOFTWARE_LIBRARY, + } + ); + + if (isTitlesLoading) { + return ; + } + + if (isTitlesError) { + return ; + } + + return ( +
+ +
+ ); +}; + +export default SoftwareLibrary; diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTable.tests.tsx b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTable.tests.tsx new file mode 100644 index 0000000000..27825dd0f0 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTable.tests.tsx @@ -0,0 +1,165 @@ +import React from "react"; +import { screen } from "@testing-library/react"; +import { createCustomRenderer, createMockRouter } from "test/test-utils"; + +import createMockUser from "__mocks__/userMock"; +import { createMockSoftwareTitlesResponse } from "__mocks__/softwareMock"; + +import SoftwareLibraryTable from "./SoftwareLibraryTable"; + +const mockRouter = createMockRouter(); + +describe("Software library table", () => { + it("Renders the page-wide disabled state when software inventory is disabled", () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + render( + + ); + + expect(screen.getByText("Software inventory disabled")).toBeInTheDocument(); + }); + + it("Renders the page-wide empty state when no software are present, hiding search", () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + render( + + ); + + expect(screen.getByText("No software available")).toBeInTheDocument(); + expect( + screen.getByText("Add software to your library to get started.") + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Add software" }) + ).toBeInTheDocument(); + expect(screen.getByText("0 items")).toBeInTheDocument(); + expect(screen.queryByText("Search")).toBeNull(); + expect(screen.queryByText("Self-service only")).toBeNull(); + }); + + it("Renders the empty search state and self-service toggle when self-service filter is applied", () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + render( + + ); + + expect( + screen.getByText("No items match the current search criteria") + ).toBeInTheDocument(); + expect(screen.getByText("Self-service only")).toBeInTheDocument(); + }); + + it("Renders the empty state without Add software button for observers", () => { + const render = createCustomRenderer({ + context: { + app: { + isGlobalAdmin: false, + isGlobalMaintainer: false, + isTeamAdmin: false, + isTeamMaintainer: false, + currentUser: createMockUser({ global_role: "observer" }), + }, + }, + }); + + render( + + ); + + expect(screen.getByText("No software available")).toBeInTheDocument(); + expect( + screen.getByText( + "Software added to this fleet's library will appear here." + ) + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Add software" }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTable.tsx b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTable.tsx new file mode 100644 index 0000000000..8ebcca9501 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTable.tsx @@ -0,0 +1,290 @@ +/** +software/library Library tab > Table +*/ + +import React, { useCallback, useContext, useMemo } from "react"; +import { InjectedRouter } from "react-router"; +import { Row } from "react-table"; + +import PATHS from "router/paths"; +import { AppContext } from "context/app"; +import { getNextLocationPath } from "utilities/helpers"; +import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants"; +import { getPathWithQueryParams } from "utilities/url"; +import { ISoftwareTitlesResponse } from "services/entities/software"; +import { ISoftwareTitle } from "interfaces/software"; + +import TableContainer from "components/TableContainer"; +import Button from "components/buttons/Button"; +import CustomLink from "components/CustomLink"; +import EmptyState from "components/EmptyState"; +import LastUpdatedText from "components/LastUpdatedText"; +import Slider from "components/forms/fields/Slider"; +import { ITableQueryData } from "components/TableContainer/TableContainer"; +import TableCount from "components/TableContainer/TableCount"; + +import EmptySoftwareTable from "pages/SoftwarePage/components/tables/EmptySoftwareTable"; + +import generateLibraryTableConfig from "./SoftwareLibraryTableConfig"; + +interface IRowProps extends Row { + original: { + id?: number; + }; +} + +interface ISoftwareLibraryTableProps { + router: InjectedRouter; + data?: ISoftwareTitlesResponse; + isSoftwareEnabled: boolean; + query: string; + perPage: number; + orderDirection: "asc" | "desc"; + orderKey: string; + selfServiceOnly: boolean; + currentPage: number; + teamId?: number; + isLoading: boolean; +} + +const baseClass = "software-library-table"; + +const SoftwareLibraryTable = ({ + router, + data, + isSoftwareEnabled, + query, + perPage, + orderDirection, + orderKey, + selfServiceOnly, + currentPage, + teamId, + isLoading, +}: ISoftwareLibraryTableProps) => { + const { + isGlobalAdmin, + isGlobalMaintainer, + isTeamAdmin, + isTeamMaintainer, + } = useContext(AppContext); + + const canAddSoftware = + isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer; + + const determineQueryParamChange = useCallback( + (newTableQuery: ITableQueryData) => { + const changedEntry = Object.entries(newTableQuery).find(([key, val]) => { + switch (key) { + case "searchQuery": + return val !== query; + case "sortDirection": + return val !== orderDirection; + case "sortHeader": + return val !== orderKey; + case "pageIndex": + return val !== currentPage; + default: + return false; + } + }); + return changedEntry?.[0] ?? ""; + }, + [currentPage, orderDirection, orderKey, query] + ); + + const generateNewQueryParams = useCallback( + (newTableQuery: ITableQueryData, changedParam: string) => { + const newQueryParam: Record = { + query: newTableQuery.searchQuery, + fleet_id: teamId, + order_direction: newTableQuery.sortDirection, + order_key: newTableQuery.sortHeader, + page: + changedParam === "pageIndex" || changedParam === "" + ? newTableQuery.pageIndex + : 0, + }; + if (selfServiceOnly) { + newQueryParam.self_service = "true"; + } + + return newQueryParam; + }, + [selfServiceOnly, teamId] + ); + + // NOTE: this is called once on initial render and every time the query changes + const onQueryChange = useCallback( + (newTableQuery: ITableQueryData) => { + const changedParam = determineQueryParamChange(newTableQuery); + + const newRoute = getNextLocationPath({ + pathPrefix: PATHS.SOFTWARE_LIBRARY, + routeTemplate: "", + queryParams: generateNewQueryParams(newTableQuery, changedParam), + }); + + router.replace(newRoute); + }, + [determineQueryParamChange, generateNewQueryParams, router] + ); + + const tableData: ISoftwareTitle[] | undefined = data?.software_titles; + + const softwareTableHeaders = useMemo(() => { + if (!data) return []; + return generateLibraryTableConfig(router, teamId); + }, [data, router, teamId]); + + // Determines if a user should be able to filter or search in the table + const hasData = tableData && tableData.length > 0; + const hasQuery = query !== ""; + + const showFilterHeaders = + isSoftwareEnabled && (hasData || hasQuery || selfServiceOnly); + + const handleSelfServiceToggle = () => { + const queryParams: Record = { + query, + fleet_id: teamId, + order_direction: orderDirection, + order_key: orderKey, + page: 0, + }; + if (!selfServiceOnly) { + queryParams.self_service = "true"; + } + + router.replace( + getNextLocationPath({ + pathPrefix: PATHS.SOFTWARE_LIBRARY, + routeTemplate: "", + queryParams, + }) + ); + }; + + const handleRowSelect = (row: IRowProps) => { + if (!row.original.id) return; + + const detailsPath = PATHS.SOFTWARE_TITLE_DETAILS( + row.original.id.toString() + ); + + router.push(getPathWithQueryParams(detailsPath, { fleet_id: teamId })); + }; + + const renderSoftwareCount = () => { + return ( + <> + + {tableData && data?.counts_updated_at && ( + + The last time software data was
+ updated, including vulnerabilities
+ and host counts. + + } + /> + )} + + ); + }; + + const renderCustomControls = () => { + return ( + + ); + }; + + const renderTableHelpText = () => ( +
+ Seeing unexpected software?{" "} + +
+ ); + + return ( +
+ { + if (!isSoftwareEnabled) { + return ; + } + if (query !== "" || selfServiceOnly) { + return ( + + ); + } + return ( + + router.push( + getPathWithQueryParams( + PATHS.SOFTWARE_ADD_FLEET_MAINTAINED, + { fleet_id: teamId } + ) + ) + } + > + Add software + + ) : undefined + } + /> + ); + }} + defaultSortHeader={orderKey} + defaultSortDirection={orderDirection} + pageIndex={currentPage} + defaultSearchQuery={query} + manualSortBy + pageSize={perPage} + showMarkAllPages={false} + isAllPagesSelected={false} + disableNextPage={!data?.meta.has_next_results} + searchable={showFilterHeaders} + inputPlaceHolder="Search by name" + onQueryChange={onQueryChange} + additionalQueries={String(selfServiceOnly)} + customControl={showFilterHeaders ? renderCustomControls : undefined} + stackControls + renderCount={renderSoftwareCount} + renderTableHelpText={renderTableHelpText} + disableMultiRowSelect + onSelectSingleRow={handleRowSelect} + /> +
+ ); +}; + +export default SoftwareLibraryTable; diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTableConfig.tests.tsx b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTableConfig.tests.tsx new file mode 100644 index 0000000000..ce940df647 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTableConfig.tests.tsx @@ -0,0 +1,60 @@ +import { createMockRouter } from "test/test-utils"; + +import generateTableHeaders from "./SoftwareLibraryTableConfig"; + +const mockRouter = createMockRouter(); + +describe("SoftwareLibraryTableConfig", () => { + const headers = generateTableHeaders(mockRouter, 1); + + it("generates the correct column headers", () => { + const headerNames = headers.map((h) => { + if (typeof h.Header === "string") return h.Header; + return h.accessor; + }); + + expect(headerNames).toEqual([ + "name", + "Installed version", + "Library version", + "Type", + "hosts_count", + "", + ]); + }); + + it("has 'Installed version' instead of 'Version'", () => { + const installedVersionCol = headers.find( + (h) => typeof h.Header === "string" && h.Header === "Installed version" + ); + const plainVersionCol = headers.find( + (h) => typeof h.Header === "string" && h.Header === "Version" + ); + + expect(installedVersionCol).toBeDefined(); + expect(plainVersionCol).toBeUndefined(); + }); + + it("has a Library version column", () => { + const libraryVersionCol = headers.find((h) => h.id === "library_version"); + expect(libraryVersionCol).toBeDefined(); + }); + + it("does not have a Vulnerabilities column", () => { + const vulnCol = headers.find( + (h) => typeof h.Header === "string" && h.Header === "Vulnerabilities" + ); + expect(vulnCol).toBeUndefined(); + }); + + it("disables sorting on Installed version, Library version, and Type", () => { + const nonSortable = headers.filter((h) => h.disableSortBy === true); + const nonSortableKeys = nonSortable.map( + (h) => (typeof h.Header === "string" ? h.Header : h.id) || h.accessor + ); + + expect(nonSortableKeys).toContain("Installed version"); + expect(nonSortableKeys).toContain("Library version"); + expect(nonSortableKeys).toContain("Type"); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTableConfig.tsx new file mode 100644 index 0000000000..dd9f9be9c6 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/SoftwareLibraryTableConfig.tsx @@ -0,0 +1,225 @@ +import React from "react"; +import { CellProps, Column } from "react-table"; +import { InjectedRouter } from "react-router"; + +import { + ISoftwareTitle, + NO_VERSION_OR_HOST_DATA_SOURCES, + formatSoftwareType, + isIpadOrIphoneSoftwareSource, +} from "interfaces/software"; +import PATHS from "router/paths"; + +import { getPathWithQueryParams } from "utilities/url"; +import { getAutomaticInstallPoliciesCount } from "pages/SoftwarePage/helpers"; +import { IHeaderProps, IStringCellProps } from "interfaces/datatable_config"; + +import HeaderCell from "components/TableContainer/DataTable/HeaderCell"; +import TextCell from "components/TableContainer/DataTable/TextCell"; +import TooltipWrapper from "components/TooltipWrapper"; +import ViewAllHostsLink from "components/ViewAllHostsLink"; +import SoftwareNameCell from "components/TableContainer/DataTable/SoftwareNameCell"; + +import VersionCell from "../../components/tables/VersionCell"; + +// NOTE: cellProps come from react-table +// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties + +type ISoftwareTitlesTableConfig = Column; +type ITableStringCellProps = IStringCellProps; +type IVersionsCellProps = CellProps; +type IHostCountCellProps = CellProps< + ISoftwareTitle, + ISoftwareTitle["hosts_count"] +>; +type IViewAllHostsLinkProps = CellProps; + +type ITableHeaderProps = IHeaderProps; + +/** + * Gets the data needed to render the software name cell. + */ +const getSoftwareNameCellData = ( + softwareTitle: ISoftwareTitle, + teamId?: number +) => { + const softwareTitleDetailsPath = getPathWithQueryParams( + PATHS.SOFTWARE_TITLE_DETAILS(softwareTitle.id.toString()), + { fleet_id: teamId } + ); + + const { software_package, app_store_app } = softwareTitle; + let hasInstaller = false; + let isSelfService = false; + let installType: "manual" | "automatic" | undefined; + let iconUrl: string | null = null; + if (software_package) { + hasInstaller = true; + isSelfService = software_package.self_service; + installType = + software_package.automatic_install_policies && + software_package.automatic_install_policies.length > 0 + ? "automatic" + : "manual"; + } else if (app_store_app) { + hasInstaller = true; + isSelfService = app_store_app.self_service; + iconUrl = app_store_app.icon_url; + installType = + app_store_app.automatic_install_policies && + app_store_app.automatic_install_policies.length > 0 + ? "automatic" + : "manual"; + } + if (softwareTitle.icon_url) { + iconUrl = softwareTitle.icon_url; + } + + const automaticInstallPoliciesCount = getAutomaticInstallPoliciesCount( + softwareTitle + ); + + return { + name: softwareTitle.name, + displayName: softwareTitle.display_name, + source: softwareTitle.source, + path: softwareTitleDetailsPath, + hasInstaller, + isSelfService, + installType, + iconUrl, + automaticInstallPoliciesCount, + }; +}; + +/** + * Gets the library version — the version of the installer package or app store app. + */ +const getLibraryVersion = (softwareTitle: ISoftwareTitle): string | null => { + if (softwareTitle.software_package) { + return softwareTitle.software_package.version || null; + } + if (softwareTitle.app_store_app) { + return softwareTitle.app_store_app.latest_version || null; + } + return null; +}; + +const generateTableHeaders = ( + router: InjectedRouter, + teamId?: number +): ISoftwareTitlesTableConfig[] => { + const softwareTableHeaders: ISoftwareTitlesTableConfig[] = [ + { + Header: (cellProps: ITableHeaderProps) => ( + + ), + disableSortBy: false, + accessor: "name", + Cell: (cellProps: ITableStringCellProps) => { + const nameCellData = getSoftwareNameCellData( + cellProps.row.original, + teamId + ); + const isAndroidPlayStoreApp = + !!cellProps.row.original.app_store_app && + cellProps.row.original.source === "android_apps"; + + return ( + + ); + }, + sortType: "caseInsensitive", + }, + { + Header: "Installed version", + disableSortBy: true, + accessor: "versions", + Cell: (cellProps: IVersionsCellProps) => ( + + ), + }, + { + Header: "Library version", + disableSortBy: true, + // Use a unique id since we can't use the same accessor twice + id: "library_version", + Cell: (cellProps: CellProps) => { + const version = getLibraryVersion(cellProps.row.original); + return ; + }, + }, + { + Header: "Type", + disableSortBy: true, + accessor: "source", + Cell: (cellProps: ITableStringCellProps) => ( + + ), + }, + { + Header: (cellProps: ITableHeaderProps) => ( + + Hosts +
+ } + disableSortBy={false} + isSortedDesc={cellProps.column.isSortedDesc} + /> + ), + disableSortBy: false, + accessor: "hosts_count", + Cell: (cellProps: IHostCountCellProps) => ( + + ), + }, + { + Header: "", + id: "view-all-hosts", + disableSortBy: true, + Cell: (cellProps: IViewAllHostsLinkProps) => { + const { source } = cellProps.row.original; + + const hostCountNotSupported = NO_VERSION_OR_HOST_DATA_SOURCES.includes( + source + ); + + if (hostCountNotSupported) return null; + + return ( + + ); + }, + }, + ]; + + return softwareTableHeaders; +}; + +export default generateTableHeaders; diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/_styles.scss b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/_styles.scss new file mode 100644 index 0000000000..84d50bc541 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/_styles.scss @@ -0,0 +1,86 @@ +.software-library-table { + // Override the grid container to a simple flex layout + // to avoid an empty grid column gap. + .container { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: $pad-medium; + } + + .top-shift-header { + align-items: center; + } + + .form-field--slider { + white-space: nowrap; + } + + .table-container { + &__search-input, + &__search { + width: 100%; // Search bar across entire table + + .input-icon-field__input { + min-width: 213px; + height: 36px; + } + + @media (min-width: $table-controls-break) { + width: auto; + + .input-icon-field__input { + width: 310px; + } + } + } + + &__data-table-block { + .data-table-block { + .data-table__table { + thead { + .name__header { + width: $col-md; + } + } + + tbody { + .name__cell { + max-width: $col-md; + + .children-wrapper { + overflow: initial; + } + + .software-name { + overflow: hidden; + text-overflow: ellipsis; + } + } + + @media (min-width: $break-sm) { + .name__cell { + max-width: $col-lg; + } + } + } + } + } + } + } + + // needed to handle overflow of the table data on small screens + .data-table { + &__wrapper { + overflow-x: auto; + } + } + + .view-all-hosts { + &__cell { + display: flex; + justify-content: flex-end; + } + } +} diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/index.ts b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/index.ts new file mode 100644 index 0000000000..0527ca7db1 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SoftwareLibraryTable/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwareLibraryTable"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/_styles.scss b/frontend/pages/SoftwarePage/SoftwareLibrary/_styles.scss similarity index 86% rename from frontend/pages/SoftwarePage/SoftwareTitles/_styles.scss rename to frontend/pages/SoftwarePage/SoftwareLibrary/_styles.scss index 858fe7cb54..69556fb595 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/_styles.scss +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/_styles.scss @@ -1,4 +1,4 @@ -.software-titles { +.software-library { @include vertical-page-tab-panel-layout; margin-top: $gap-page-component; // Required as these Tabs don't use TabPanel } diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/index.ts b/frontend/pages/SoftwarePage/SoftwareLibrary/index.ts new file mode 100644 index 0000000000..a0b4060639 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwareLibrary"; diff --git a/frontend/pages/SoftwarePage/SoftwarePage.tests.tsx b/frontend/pages/SoftwarePage/SoftwarePage.tests.tsx new file mode 100644 index 0000000000..4ae0fbee34 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwarePage.tests.tsx @@ -0,0 +1,79 @@ +import PATHS from "router/paths"; + +import { + softwareSubNav, + premiumSoftwareSubNav, + getTabIndex, +} from "./SoftwarePage"; + +// These are not exported by default — we'll test the logic via the exported +// nav arrays and getTabIndex. If they aren't exported yet, see note below. + +describe("SoftwarePage tab configuration", () => { + describe("softwareSubNav (free tier)", () => { + it("includes Inventory, OS, and Vulnerabilities tabs", () => { + const names = softwareSubNav.map((item) => item.name); + expect(names).toEqual(["Inventory", "OS", "Vulnerabilities"]); + }); + + it("does not include Library tab", () => { + const names = softwareSubNav.map((item) => item.name); + expect(names).not.toContain("Library"); + }); + + it("points Inventory to SOFTWARE_INVENTORY path", () => { + const inventory = softwareSubNav.find( + (item) => item.name === "Inventory" + ); + expect(inventory?.pathname).toBe(PATHS.SOFTWARE_INVENTORY); + }); + }); + + describe("premiumSoftwareSubNav (premium tier)", () => { + it("includes Inventory, OS, Vulnerabilities, and Library tabs", () => { + const names = premiumSoftwareSubNav.map((item) => item.name); + expect(names).toEqual(["Inventory", "OS", "Vulnerabilities", "Library"]); + }); + + it("points Library to SOFTWARE_LIBRARY path", () => { + const library = premiumSoftwareSubNav.find( + (item) => item.name === "Library" + ); + expect(library?.pathname).toBe(PATHS.SOFTWARE_LIBRARY); + }); + }); + + describe("getTabIndex", () => { + it("returns the Inventory tab index for the inventory path", () => { + expect(getTabIndex(PATHS.SOFTWARE_INVENTORY, premiumSoftwareSubNav)).toBe( + 0 + ); + }); + + it("returns the Inventory tab index for the versions path", () => { + expect(getTabIndex(PATHS.SOFTWARE_VERSIONS, premiumSoftwareSubNav)).toBe( + 0 + ); + }); + + it("returns the OS tab index for the OS path", () => { + expect(getTabIndex(PATHS.SOFTWARE_OS, premiumSoftwareSubNav)).toBe(1); + }); + + it("returns the Vulnerabilities tab index for the vulnerabilities path", () => { + expect( + getTabIndex(PATHS.SOFTWARE_VULNERABILITIES, premiumSoftwareSubNav) + ).toBe(2); + }); + + it("returns the Library tab index for the library path", () => { + expect(getTabIndex(PATHS.SOFTWARE_LIBRARY, premiumSoftwareSubNav)).toBe( + 3 + ); + }); + + it("returns -1 for an unknown path", () => { + expect(getTabIndex("/software/unknown", premiumSoftwareSubNav)).toBe(-1); + }); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwarePage.tsx b/frontend/pages/SoftwarePage/SoftwarePage.tsx index 727a2901f4..d95bd119ff 100644 --- a/frontend/pages/SoftwarePage/SoftwarePage.tsx +++ b/frontend/pages/SoftwarePage/SoftwarePage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useContext, useState } from "react"; +import React, { useCallback, useContext, useEffect, useState } from "react"; import { InjectedRouter } from "react-router"; import { useQuery } from "react-query"; import { Tab, TabList, Tabs } from "react-tabs"; @@ -32,12 +32,10 @@ import PageDescription from "components/PageDescription"; import ManageAutomationsModal from "./components/modals/ManageSoftwareAutomationsModal"; import AddSoftwareModal from "./components/modals/AddSoftwareModal"; import { - buildSoftwareFilterQueryParams, buildSoftwareVulnFiltersQueryParams, - getSoftwareFilterFromQueryParams, getSoftwareVulnFiltersFromQueryParams, ISoftwareVulnFiltersParams, -} from "./SoftwareTitles/SoftwareTable/helpers"; +} from "./SoftwareInventory/SoftwareInventoryTable/helpers"; import SoftwareFiltersModal from "./components/modals/SoftwareFiltersModal"; interface ISoftwareSubNavItem { @@ -45,10 +43,10 @@ interface ISoftwareSubNavItem { pathname: string; } -const softwareSubNav: ISoftwareSubNavItem[] = [ +export const softwareSubNav: ISoftwareSubNavItem[] = [ { - name: "Software", - pathname: PATHS.SOFTWARE_TITLES, + name: "Inventory", + pathname: PATHS.SOFTWARE_INVENTORY, }, { name: "OS", @@ -60,13 +58,25 @@ const softwareSubNav: ISoftwareSubNavItem[] = [ }, ]; -const getTabIndex = (path: string): number => { - return softwareSubNav.findIndex((navItem) => { +export const premiumSoftwareSubNav: ISoftwareSubNavItem[] = [ + ...softwareSubNav, + { + name: "Library", + pathname: PATHS.SOFTWARE_LIBRARY, + }, +]; + +export const getTabIndex = ( + path: string, + navItems: ISoftwareSubNavItem[] +): number => { + return navItems.findIndex((navItem) => { // This check ensures that for software versions path we still // highlight the software tab. - if (navItem.name === "Software" && PATHS.SOFTWARE_VERSIONS === path) { + if (navItem.name === "Inventory" && PATHS.SOFTWARE_VERSIONS === path) { return true; } + // tab stays highlighted for paths that start with same pathname return path.startsWith(navItem.pathname); }); @@ -75,7 +85,9 @@ const getTabIndex = (path: string): number => { // 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; +// Increased from 20 to 50 per design spec (#32128). Load test the software +// endpoints before shipping to confirm acceptable response times at this threshold. +const DEFAULT_PAGE_SIZE = 50; const DEFAULT_PAGE = 0; const baseClass = "software-page"; @@ -152,15 +164,13 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { ? parseInt(queryParams.page, 10) : DEFAULT_PAGE; const platform = queryParams?.platform || "all"; - // TODO: move these down into the Software Titles component. + // TODO: move query/filter parsing down into individual tab components const query = queryParams && queryParams.query ? queryParams.query : ""; const showExploitedVulnerabilitiesOnly = queryParams !== undefined && queryParams.exploit === "true"; - // TODO: there should be better validation of the params depending on the route (e.g., self_service - // and available_for_install don't apply to versions, os, or vulnerabilities routes) and some - // defined redirect behavior if the params are invalid - const softwareFilter = getSoftwareFilterFromQueryParams(queryParams); + // Library uses a self-service toggle (boolean), not the old dropdown filter + const selfServiceOnly = queryParams?.self_service === "true"; const softwareVulnFilters = getSoftwareVulnFiltersFromQueryParams( queryParams @@ -187,10 +197,8 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { router, includeAllTeams: true, includeNoTeam: true, - // When switching to "All teams" context, remove any unsupported query params that might be set + // When switching to "All fleets", remove self_service param (Library-only) overrideParamsOnTeamChange: { - available_for_install: (newTeamId: number | undefined) => - newTeamId === APP_CONTEXT_ALL_TEAMS_ID, self_service: (newTeamId: number | undefined) => newTeamId === APP_CONTEXT_ALL_TEAMS_ID, }, @@ -283,6 +291,30 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { [handleTeamChange] ); + // Redirect away from Library tab if not allowed: + // - Free tier doesn't have Library + // - "All fleets" can't view Library + const isOnLibraryTab = location.pathname.startsWith(PATHS.SOFTWARE_LIBRARY); + useEffect(() => { + // Wait for config to load before deciding — isPremiumTier is undefined + // until then, and !undefined would incorrectly bounce premium users. + if (isPremiumTier === undefined) return; + + if (isOnLibraryTab && (!isPremiumTier || isAllTeamsSelected)) { + router.replace( + getPathWithQueryParams(PATHS.SOFTWARE_INVENTORY, { + fleet_id: currentTeamId, + }) + ); + } + }, [ + isPremiumTier, + isAllTeamsSelected, + isOnLibraryTab, + currentTeamId, + router, + ]); + const onApplyVulnFilters = (vulnFilters: ISoftwareVulnFiltersParams) => { const newQueryParams: ISoftwareApiParams = { query, @@ -290,7 +322,6 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { orderDirection: sortDirection, orderKey: sortHeader, page: 0, // resets page index - ...buildSoftwareFilterQueryParams(softwareFilter), ...buildSoftwareVulnFiltersQueryParams(vulnFilters), }; @@ -304,6 +335,8 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { toggleSoftwareFiltersModal(); }; + const navItems = isPremiumTier ? premiumSoftwareSubNav : softwareSubNav; + const navigateToNav = useCallback( (i: number): void => { // Only query param to persist between tabs is team id @@ -312,14 +345,10 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { page: 0, // Fixes flakey page reset in API call when switching between tabs }; - const navPath = getPathWithQueryParams( - softwareSubNav[i].pathname, - teamIdParam - ); - + const navPath = getPathWithQueryParams(navItems[i].pathname, teamIdParam); router.replace(navPath); }, - [location, router] + [location?.query.fleet_id, navItems, router] ); const renderPageActions = () => { @@ -389,15 +418,36 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { }; const renderBody = () => { + const isLibraryDisabled = isAllTeamsSelected; + return (
navigateToNav(i)} > - {softwareSubNav.map((navItem) => { + {navItems.map((navItem) => { + const isDisabledTab = + navItem.name === "Library" && isLibraryDisabled; + + if (isDisabledTab) { + return ( + + + {navItem.name} + + + ); + } + return ( {navItem.name} @@ -422,7 +472,7 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { platform, query, showExploitedVulnerabilitiesOnly, - softwareFilter, + selfServiceOnly, vulnFilters: softwareVulnFilters, onAddFiltersClick: toggleSoftwareFiltersModal, })} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tsx index 7db215e1c1..247eda6469 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tsx @@ -707,11 +707,14 @@ const EditIconModal = ({ {displayName === "" ? previewInfo.name : displayName}. ); - // Invalidate software titles list cache so the edit is reflected + // Invalidate software list caches so the edit is reflected // if the user navigates back before the stale time has passed. queryClient.invalidateQueries({ queryKey: [{ scope: "software-titles" }], }); + queryClient.invalidateQueries({ + queryKey: [{ scope: "software-library" }], + }); refetchSoftwareTitle(); setIconUploadedAt(new Date().toISOString()); onExitEditIconModal(); @@ -720,6 +723,9 @@ const EditIconModal = ({ queryClient.invalidateQueries({ queryKey: [{ scope: "software-titles" }], }); + queryClient.invalidateQueries({ + queryKey: [{ scope: "software-library" }], + }); refetchSoftwareTitle(); setIconUploadedAt(new Date().toISOString()); onExitEditIconModal(); @@ -728,6 +734,9 @@ const EditIconModal = ({ queryClient.invalidateQueries({ queryKey: [{ scope: "software-titles" }], }); + queryClient.invalidateQueries({ + queryKey: [{ scope: "software-library" }], + }); refetchSoftwareTitle(); setIconUploadedAt(new Date().toISOString()); onExitEditIconModal(); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx index 00a481b29e..8b14f39e55 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx @@ -1,5 +1,5 @@ import React, { useContext, useState, useEffect } from "react"; -import { useQuery } from "react-query"; +import { useQuery, useQueryClient } from "react-query"; import classnames from "classnames"; import { ILabelSummary } from "interfaces/label"; @@ -73,6 +73,7 @@ const EditSoftwareModal = ({ iconUrl = undefined, }: IEditSoftwareModalProps) => { const { renderFlash } = useContext(NotificationContext); + const queryClient = useQueryClient(); const { gitOpsModeEnabled } = useGitOpsMode("software"); // Viewing an FMA in GitOps mode only allows viewing options, not editing const isGitOpsCompatible = gitOpsModeEnabled && isFleetMaintainedApp; @@ -235,6 +236,14 @@ const EditSoftwareModal = ({ ); } + // Invalidate both list caches so edits (e.g. self-service toggle) + // are reflected when navigating back to Inventory or Library tabs + queryClient.invalidateQueries({ + queryKey: [{ scope: "software-titles" }], + }); + queryClient.invalidateQueries({ + queryKey: [{ scope: "software-library" }], + }); refetchSoftwareTitle(); onExit(); } catch (e) { @@ -300,6 +309,14 @@ const EditSoftwareModal = ({ : ""} ); + // Invalidate both list caches so edits (e.g. self-service toggle) + // are reflected when navigating back to Inventory or Library tabs + queryClient.invalidateQueries({ + queryKey: [{ scope: "software-titles" }], + }); + queryClient.invalidateQueries({ + queryKey: [{ scope: "software-library" }], + }); onExit(); refetchSoftwareTitle(); } catch (e) { diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx index 0e6f2ba160..fcc03059b0 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx @@ -117,15 +117,18 @@ const SoftwareTitleDetailsPage = ({ const onDeleteInstaller = useCallback(() => { queryClient.invalidateQueries({ queryKey: [{ scope: "software-titles" }] }); + queryClient.invalidateQueries({ + queryKey: [{ scope: "software-library" }], + }); if (softwareTitle?.versions?.length) { refetchSoftwareTitle(); return; } - // redirect to software titles page if no versions are available + // redirect to software library page if no versions are available router.push( - getPathWithQueryParams(paths.SOFTWARE_TITLES, { + getPathWithQueryParams(paths.SOFTWARE_LIBRARY, { fleet_id: teamIdForApi, }) ); diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/index.ts b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/index.ts deleted file mode 100644 index 22a43d3b69..0000000000 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./SoftwareTable"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/index.ts b/frontend/pages/SoftwarePage/SoftwareTitles/index.ts deleted file mode 100644 index 36fd17a897..0000000000 --- a/frontend/pages/SoftwarePage/SoftwareTitles/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./SoftwareTitles"; diff --git a/frontend/pages/SoftwarePage/components/modals/SoftwareFiltersModal/SoftwareFiltersModal.tsx b/frontend/pages/SoftwarePage/components/modals/SoftwareFiltersModal/SoftwareFiltersModal.tsx index ab58da2b00..87d6a0e481 100644 --- a/frontend/pages/SoftwarePage/components/modals/SoftwareFiltersModal/SoftwareFiltersModal.tsx +++ b/frontend/pages/SoftwarePage/components/modals/SoftwareFiltersModal/SoftwareFiltersModal.tsx @@ -17,7 +17,7 @@ import { findOptionBySeverityRange, ISoftwareVulnFiltersParams, SEVERITY_DROPDOWN_OPTIONS, -} from "pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers"; +} from "pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/helpers"; const baseClass = "software-filters-modal"; diff --git a/frontend/pages/SoftwarePage/components/tables/EmptySoftwareTable/EmptySoftwareTable.tsx b/frontend/pages/SoftwarePage/components/tables/EmptySoftwareTable/EmptySoftwareTable.tsx index 1f23f1540d..3c809eb372 100644 --- a/frontend/pages/SoftwarePage/components/tables/EmptySoftwareTable/EmptySoftwareTable.tsx +++ b/frontend/pages/SoftwarePage/components/tables/EmptySoftwareTable/EmptySoftwareTable.tsx @@ -4,13 +4,11 @@ import EmptyState from "components/EmptyState"; import { IEmptyStateProps } from "interfaces/empty_state"; import { getVulnFilterRenderDetails, - ISoftwareDropdownFilterVal, ISoftwareVulnFiltersParams, -} from "pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers"; +} from "pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/helpers"; import { HostPlatform, isAndroid } from "interfaces/platform"; export interface IEmptySoftwareTableProps { - softwareFilter?: ISoftwareDropdownFilterVal; vulnFilters?: ISoftwareVulnFiltersParams; tableName?: string; isSoftwareDisabled?: boolean; @@ -21,12 +19,8 @@ export interface IEmptySoftwareTableProps { const generateTypeText = ( tableName: string, - softwareFilter?: ISoftwareDropdownFilterVal, vulnFilters?: ISoftwareVulnFiltersParams ) => { - if (softwareFilter === "installableSoftware") { - return "installable software"; - } if (vulnFilters?.vulnerable) { return "vulnerable software"; } @@ -34,26 +28,20 @@ const generateTypeText = ( }; const EmptySoftwareTable = ({ - softwareFilter = "allSoftware", vulnFilters, tableName = "software", isSoftwareDisabled, - noSearchQuery, + noSearchQuery = true, installableSoftwareExists, platform, }: IEmptySoftwareTableProps): JSX.Element => { - const softwareTypeText = generateTypeText( - tableName, - softwareFilter, - vulnFilters - ); + const softwareTypeText = generateTypeText(tableName, vulnFilters); const { filterCount: vulnFiltersCount } = getVulnFilterRenderDetails( vulnFilters ); - const isFiltered = - vulnFiltersCount > 0 || !noSearchQuery || softwareFilter !== "allSoftware"; + const isFiltered = vulnFiltersCount > 0 || !noSearchQuery; const getEmptySoftwareInfo = (): IEmptyStateProps => { if (isSoftwareDisabled) { @@ -79,18 +67,16 @@ const EmptySoftwareTable = ({ } if (!isFiltered) { - if (softwareFilter === "allSoftware") { - if (installableSoftwareExists) { - return { - header: `No ${tableName} detected`, - info: "Install software on your hosts to see versions.", - }; - } + if (installableSoftwareExists) { return { header: `No ${tableName} detected`, - info, + info: "Install software on your hosts to see versions.", }; } + return { + header: `No ${tableName} detected`, + info, + }; } return { diff --git a/frontend/pages/hosts/details/cards/Software/DeviceSoftwareTableConfig.tsx b/frontend/pages/hosts/details/cards/Software/DeviceSoftwareTableConfig.tsx index 826eb10356..bffdd1ac8f 100644 --- a/frontend/pages/hosts/details/cards/Software/DeviceSoftwareTableConfig.tsx +++ b/frontend/pages/hosts/details/cards/Software/DeviceSoftwareTableConfig.tsx @@ -8,7 +8,7 @@ import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCel import VulnerabilitiesCell from "pages/SoftwarePage/components/tables/VulnerabilitiesCell"; import VersionCell from "pages/SoftwarePage/components/tables/VersionCell"; -import { getVulnerabilities } from "pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers"; +import { getVulnerabilities } from "pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/helpers"; import SoftwareNameCell from "components/TableContainer/DataTable/SoftwareNameCell"; import TooltipTruncatedTextCell from "components/TableContainer/DataTable/TooltipTruncatedTextCell"; diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx index 1de76fc3c9..248f7b8495 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx @@ -31,7 +31,7 @@ import { buildSoftwareVulnFiltersQueryParams, getSoftwareVulnFiltersFromQueryParams, ISoftwareVulnFiltersParams, -} from "pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers"; +} from "pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/helpers"; import { generateSoftwareTableHeaders as generateHostSoftwareTableConfig } from "./HostSoftwareTableConfig"; import { generateSoftwareTableHeaders as generateDeviceSoftwareTableConfig } from "./DeviceSoftwareTableConfig"; import HostSoftwareTable from "./HostSoftwareTable"; diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx index e9400440ff..39b3e0a3fd 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx @@ -10,7 +10,7 @@ import { buildSoftwareVulnFiltersQueryParams, getVulnFilterRenderDetails, ISoftwareVulnFiltersParams, -} from "pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers"; +} from "pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/helpers"; import { HostPlatform, @@ -30,9 +30,6 @@ import TableCount from "components/TableContainer/TableCount"; import { VulnsNotSupported } from "pages/SoftwarePage/components/tables/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable"; import { Row } from "react-table"; import { IHostSoftware } from "interfaces/software"; -import EmptyState from "components/EmptyState"; -import CustomLink from "components/CustomLink"; -import { SUPPORT_LINK } from "utilities/constants"; const DEFAULT_PAGE_SIZE = 20; @@ -225,7 +222,7 @@ const HostSoftwareTable = ({ searchQuery={searchQuery} /> )} - customFiltersButton={ + customControl={ showFilterHeaders ? renderCustomFiltersButton : undefined } stackControls diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx index 2610257cca..ab2ae305a7 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx @@ -29,7 +29,7 @@ import { HumanTimeDiffWithDateTip } from "components/HumanTimeDiffWithDateTip"; import VulnerabilitiesCell from "pages/SoftwarePage/components/tables/VulnerabilitiesCell"; import VersionCell from "pages/SoftwarePage/components/tables/VersionCell"; -import { getVulnerabilities } from "pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers"; +import { getVulnerabilities } from "pages/SoftwarePage/SoftwareInventory/SoftwareInventoryTable/helpers"; import { getAutomaticInstallPoliciesCount } from "pages/SoftwarePage/helpers"; import TooltipTruncatedTextCell from "components/TableContainer/DataTable/TooltipTruncatedTextCell"; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx index 8c6815e6a1..cb07e3dc74 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx @@ -277,9 +277,12 @@ const InstallSoftwareModal = ({ <> Go to{" "} {" "} to add software to this fleet. diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx index d2e6d2e83f..dbf6ed24c1 100644 --- a/frontend/router/index.tsx +++ b/frontend/router/index.tsx @@ -76,9 +76,10 @@ import AppleBusinessManagerPage from "pages/admin/IntegrationsPage/cards/MdmSett import VppPage from "pages/admin/IntegrationsPage/cards/MdmSettings/VppPage"; import HostQueryReport from "pages/hosts/details/HostQueryReport"; import SoftwarePage from "pages/SoftwarePage"; -import SoftwareTitles from "pages/SoftwarePage/SoftwareTitles"; +import SoftwareInventory from "pages/SoftwarePage/SoftwareInventory"; import SoftwareOS from "pages/SoftwarePage/SoftwareOS"; import SoftwareVulnerabilities from "pages/SoftwarePage/SoftwareVulnerabilities"; +import SoftwareLibrary from "pages/SoftwarePage/SoftwareLibrary"; import SoftwareTitleDetailsPage from "pages/SoftwarePage/SoftwareTitleDetailsPage"; import SoftwareVersionDetailsPage from "pages/SoftwarePage/SoftwareVersionDetailsPage"; import TeamSettings from "pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamSettings"; @@ -351,9 +352,10 @@ const routes = ( /> - - {/* we check the add route first otherwise a route like 'software/add' will be caught - * by the 'software/:id' redirect and be redirected to 'software/versions/add */} + + {/* Legacy route redirect */} + + {/* Check the add route first so 'software/add' isn't caught by title/version detail routes */} @@ -370,23 +372,24 @@ const routes = ( /> - - + + - {/* This redirect keeps the old software/:id working */} + + {/* Legacy redirect: keeps old /software/:id URLs working */} + + + - - - diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 0f160bd77c..768714ba31 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -85,9 +85,10 @@ export default { // Software pages SOFTWARE: `${URL_PREFIX}/software`, - SOFTWARE_TITLES: `${URL_PREFIX}/software/titles`, + SOFTWARE_INVENTORY: `${URL_PREFIX}/software/inventory`, SOFTWARE_OS: `${URL_PREFIX}/software/os`, SOFTWARE_VERSIONS: `${URL_PREFIX}/software/versions`, + SOFTWARE_LIBRARY: `${URL_PREFIX}/software/library`, SOFTWARE_TITLE_DETAILS: (id: string): string => { return `${URL_PREFIX}/software/titles/${id}`; }, diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index f797b5377a..0abc3d22f2 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -89,7 +89,7 @@ export interface ISoftwareVersionsQueryKey extends ISoftwareApiParams { export interface ISoftwareTitlesQueryKey extends ISoftwareApiParams { platform?: CommaSeparatedPlatformString; - scope: "software-titles"; + scope: "software-titles" | "software-library"; } export interface ISoftwareQueryKey extends ISoftwareApiParams { diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index d1c6d61510..c69ce5e259 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -234,7 +234,7 @@ export default { // Software endpoints SOFTWARE: `/${API_VERSION}/fleet/software`, - SOFTWARE_TITLES: `/${API_VERSION}/fleet/software/titles`, + SOFTWARE_TITLES: `/${API_VERSION}/fleet/software/titles`, // Powers software/inventory and software/library pages SOFTWARE_TITLE: (id: number) => `/${API_VERSION}/fleet/software/titles/${id}`, EDIT_SOFTWARE_PACKAGE: (id: number) => `/${API_VERSION}/fleet/software/titles/${id}/package`, diff --git a/frontend/utilities/numbers/index.ts b/frontend/utilities/numbers/index.ts new file mode 100644 index 0000000000..ecd322a894 --- /dev/null +++ b/frontend/utilities/numbers/index.ts @@ -0,0 +1 @@ +export { default } from "./numberUtils"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.tests.ts b/frontend/utilities/numbers/numberUtils.tests.ts similarity index 54% rename from frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.tests.ts rename to frontend/utilities/numbers/numberUtils.tests.ts index 9f5e598b30..7508673d55 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.tests.ts +++ b/frontend/utilities/numbers/numberUtils.tests.ts @@ -1,4 +1,6 @@ -import { isValidNumber, getVulnerabilities } from "./helpers"; +import numberUtils from "utilities/numbers"; + +const { isValidNumber } = numberUtils; describe("isValidNumber", () => { // Test valid numbers @@ -47,54 +49,3 @@ describe("isValidNumber", () => { expect(isValidNumber(11, 0, 10)).toBe(false); }); }); - -const versions = [ - { - id: 531270, - version: "131.0.6778.86", - vulnerabilities: ["CVE-2024-12053", "CVE-2024-12381", "CVE-2025-0444"], - }, - { - id: 538184, - version: "132.0.6834.160", - vulnerabilities: ["CVE-2025-0444", "CVE-2025-0445"], // 0444 is duplicate - }, - { - id: 541233, - version: "133.0.6943.53", - vulnerabilities: ["CVE-2025-0995", "CVE-2025-0996"], - }, - { - id: 572993, - version: "139.0.7258.127", - vulnerabilities: null, // should be ignored - }, -]; - -describe("getVulnerabilities", () => { - it("returns a unique list of vulnerabilities across all versions", () => { - const result = getVulnerabilities(versions); - - // Expect no duplicates - expect(new Set(result).size).toBe(result.length); - - // Expect specific vulns present - expect(result).toEqual( - expect.arrayContaining([ - "CVE-2024-12053", - "CVE-2024-12381", - "CVE-2025-0444", - "CVE-2025-0445", - "CVE-2025-0995", - "CVE-2025-0996", - ]) - ); - - // Should not contain unintended values - expect(result).not.toContain("CVE-DOES-NOT-EXIST"); - }); - - it("returns an empty array if no versions are given", () => { - expect(getVulnerabilities([])).toEqual([]); - }); -}); diff --git a/frontend/utilities/numbers/numberUtils.ts b/frontend/utilities/numbers/numberUtils.ts new file mode 100644 index 0000000000..e7eabc4a4e --- /dev/null +++ b/frontend/utilities/numbers/numberUtils.ts @@ -0,0 +1,17 @@ +/** Type guard that checks if a value is a valid number, optionally within a range. */ +const isValidNumber = ( + value: unknown, + min?: number, + max?: number +): value is number => { + if (typeof value !== "number" || isNaN(value)) { + return false; + } + + // If min or max is provided, check if the number is within the range + return ( + (min === undefined || value >= min) && (max === undefined || value <= max) + ); +}; + +export default { isValidNumber };