fleet/frontend/pages/SoftwarePage/SoftwarePage.tsx

541 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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";
import PATHS from "router/paths";
import { IConfig } from "interfaces/config";
import { IJiraIntegration, IZendeskIntegration } from "interfaces/integration";
import { APP_CONTEXT_ALL_TEAMS_ID, ITeamConfig } from "interfaces/team";
import { SelectedPlatform } from "interfaces/platform";
import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook";
import configAPI from "services/entities/config";
import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
import { ISoftwareApiParams } from "services/entities/software";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import useTeamIdParam from "hooks/useTeamIdParam";
import {
convertParamsToSnakeCase,
getPathWithQueryParams,
} from "utilities/url";
import { getNextLocationPath } from "utilities/helpers";
import Button from "components/buttons/Button";
import MainContent from "components/MainContent";
import TeamsHeader from "components/TeamsHeader";
import TooltipWrapper from "components/TooltipWrapper";
import TabNav from "components/TabNav";
import TabText from "components/TabText";
import PageDescription from "components/PageDescription";
import ManageAutomationsModal from "./components/modals/ManageSoftwareAutomationsModal";
import AddSoftwareModal from "./components/modals/AddSoftwareModal";
import {
buildSoftwareVulnFiltersQueryParams,
getSoftwareVulnFiltersFromQueryParams,
ISoftwareVulnFiltersParams,
} from "./SoftwareInventory/SoftwareInventoryTable/helpers";
import SoftwareFiltersModal from "./components/modals/SoftwareFiltersModal";
interface ISoftwareSubNavItem {
name: string;
pathname: string;
}
export const softwareSubNav: ISoftwareSubNavItem[] = [
{
name: "Inventory",
pathname: PATHS.SOFTWARE_INVENTORY,
},
{
name: "OS",
pathname: PATHS.SOFTWARE_OS,
},
{
name: "Vulnerabilities",
pathname: PATHS.SOFTWARE_VULNERABILITIES,
},
];
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 === "Inventory" && PATHS.SOFTWARE_VERSIONS === path) {
return true;
}
// 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";
// 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";
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: {
fleet_id?: string;
available_for_install?: string;
self_service?: string;
vulnerable?: string;
exploit?: string;
min_cvss_score?: string;
max_cvss_score?: string;
page?: string;
query?: string;
order_key?: string;
order_direction?: "asc" | "desc";
platform?: SelectedPlatform;
};
hash?: string;
};
router: InjectedRouter; // v3
}
const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
const {
config: globalConfigFromContext,
isFreeTier,
isGlobalAdmin,
isGlobalMaintainer,
isOnGlobalTeam,
isTeamAdmin,
isTeamMaintainer,
isPremiumTier,
} = useContext(AppContext);
const isPrimoMode =
globalConfigFromContext?.partnerships?.enable_primo || false;
const { renderFlash } = useContext(NotificationContext);
const queryParams = location.query;
// initial values for query params used on this page
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 platform = queryParams?.platform || "all";
// TODO: move query/filter parsing down into individual tab components
const query = queryParams && queryParams.query ? queryParams.query : "";
const showExploitedVulnerabilitiesOnly =
queryParams !== undefined && queryParams.exploit === "true";
// Library uses a self-service toggle (boolean), not the old dropdown filter
const selfServiceOnly = queryParams?.self_service === "true";
const softwareVulnFilters = getSoftwareVulnFiltersFromQueryParams(
queryParams
);
const [showManageAutomationsModal, setShowManageAutomationsModal] = useState(
false
);
const [showPreviewPayloadModal, setShowPreviewPayloadModal] = useState(false);
const [showPreviewTicketModal, setShowPreviewTicketModal] = useState(false);
const [showAddSoftwareModal, setShowAddSoftwareModal] = useState(false);
const [showSoftwareFiltersModal, setShowSoftwareFiltersModal] = useState(
false
);
const {
currentTeamId,
isAllTeamsSelected,
isRouteOk,
teamIdForApi,
userTeams,
handleTeamChange,
} = useTeamIdParam({
location,
router,
includeAllTeams: true,
includeNoTeam: true,
// When switching to "All fleets", remove self_service param (Library-only)
overrideParamsOnTeamChange: {
self_service: (newTeamId: number | undefined) =>
newTeamId === APP_CONTEXT_ALL_TEAMS_ID,
},
});
// 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];
// No team > Global config
return teamId ? teamsAPI.load(teamId) : configAPI.loadAll();
},
{
enabled: isRouteOk,
select: (data) => ("team" in data ? data.team : data),
}
);
const isSoftwareConfigLoaded =
!isFetchingSoftwareConfig && !softwareConfigError && !!softwareConfig;
const toggleManageAutomationsModal = useCallback(() => {
setShowManageAutomationsModal(!showManageAutomationsModal);
}, [setShowManageAutomationsModal, showManageAutomationsModal]);
const togglePreviewPayloadModal = useCallback(() => {
setShowPreviewPayloadModal(!showPreviewPayloadModal);
}, [setShowPreviewPayloadModal, showPreviewPayloadModal]);
const togglePreviewTicketModal = useCallback(() => {
setShowPreviewTicketModal(!showPreviewTicketModal);
}, [setShowPreviewTicketModal, showPreviewTicketModal]);
const toggleSoftwareFiltersModal = useCallback(() => {
setShowSoftwareFiltersModal(!showSoftwareFiltersModal);
}, [setShowSoftwareFiltersModal, showSoftwareFiltersModal]);
// 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 onAddSoftware = useCallback(() => {
if (currentTeamId === APP_CONTEXT_ALL_TEAMS_ID) {
setShowAddSoftwareModal(true);
} else {
router.push(
getPathWithQueryParams(PATHS.SOFTWARE_ADD_FLEET_MAINTAINED, {
fleet_id: currentTeamId,
})
);
}
}, [currentTeamId, router]);
const onTeamChange = useCallback(
(teamId: number) => {
handleTeamChange(teamId);
},
[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,
teamId: currentTeamId,
orderDirection: sortDirection,
orderKey: sortHeader,
page: 0, // resets page index
...buildSoftwareVulnFiltersQueryParams(vulnFilters),
};
router.replace(
getNextLocationPath({
pathPrefix: location.pathname,
routeTemplate: "",
queryParams: convertParamsToSnakeCase(newQueryParams),
})
);
toggleSoftwareFiltersModal();
};
const navItems = isPremiumTier ? premiumSoftwareSubNav : softwareSubNav;
const navigateToNav = useCallback(
(i: number): void => {
// Only query param to persist between tabs is team id
const teamIdParam = {
fleet_id: location?.query.fleet_id,
page: 0, // Fixes flakey page reset in API call when switching between tabs
};
const navPath = getPathWithQueryParams(navItems[i].pathname, teamIdParam);
router.replace(navPath);
},
[location?.query.fleet_id, navItems, router]
);
const renderPageActions = () => {
const canManageAutomations = isGlobalAdmin;
const canAddSoftware =
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
if (!isSoftwareConfigLoaded) return null;
return (
<div className={`${baseClass}__action-buttons`}>
{canManageAutomations && (
<TooltipWrapper
underline={false}
tipContent={
<div className={`${baseClass}__header__tooltip`}>
Select &ldquo;All fleets&rdquo; to manage automations.
</div>
}
disableTooltip={isAllTeamsSelected || isPrimoMode}
position="top"
showArrow
>
<Button
// TODO(Product) - Why not enable managing global automations when on any team like this
// for everyone?
disabled={!isAllTeamsSelected && !isPrimoMode}
onClick={toggleManageAutomationsModal}
className={`${baseClass}__manage-automations`}
variant="inverse"
>
Manage automations
</Button>
</TooltipWrapper>
)}
{canAddSoftware && (
<TooltipWrapper
underline={false}
tipContent={
<div className={`${baseClass}__header__tooltip`}>
{isPremiumTier
? "Select a fleet to add software."
: "This feature is included in Fleet Premium."}
</div>
}
disableTooltip={!isAllTeamsSelected}
position="top"
showArrow
>
<Button onClick={onAddSoftware} disabled={isAllTeamsSelected}>
<span>Add software</span>
</Button>
</TooltipWrapper>
)}
</div>
);
};
const renderHeaderDescription = () => {
return (
<>
Manage software and search for installed software, OS, and
vulnerabilities.
</>
);
};
const renderBody = () => {
const isLibraryDisabled = isAllTeamsSelected;
return (
<div>
<TabNav>
<Tabs
selectedIndex={getTabIndex(location?.pathname || "", navItems)}
onSelect={(i) => navigateToNav(i)}
>
<TabList>
{navItems.map((navItem) => {
const isDisabledTab =
navItem.name === "Library" && isLibraryDisabled;
if (isDisabledTab) {
return (
<Tab key={navItem.name} data-text={navItem.name} disabled>
<TooltipWrapper
tipContent="Select a fleet to view its software library."
showArrow
position="top"
tipOffset={12}
underline={false}
>
<TabText>{navItem.name}</TabText>
</TooltipWrapper>
</Tab>
);
}
return (
<Tab key={navItem.name} data-text={navItem.name}>
<TabText>{navItem.name}</TabText>
</Tab>
);
})}
</TabList>
</Tabs>
</TabNav>
<div key={location?.pathname} className="tab-nav-routed-content">
{React.cloneElement(children, {
router,
isSoftwareEnabled: Boolean(
softwareConfig?.features?.enable_software_inventory
),
perPage: DEFAULT_PAGE_SIZE,
orderDirection: sortDirection,
orderKey: sortHeader,
currentPage: page,
teamId: teamIdForApi,
// TODO: move down into the Software Titles component
platform,
query,
showExploitedVulnerabilitiesOnly,
selfServiceOnly,
vulnFilters: softwareVulnFilters,
onAddFiltersClick: toggleSoftwareFiltersModal,
})}
</div>
</div>
);
};
return (
<MainContent className={baseClass}>
<>
<div className={`${baseClass}__header-wrap`}>
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__text`}>
<div className={`${baseClass}__title`}>
{isPremiumTier && !isPrimoMode ? (
<TeamsHeader
isOnGlobalTeam={isOnGlobalTeam}
currentTeamId={currentTeamId}
userTeams={userTeams}
onTeamChange={onTeamChange}
/>
) : (
<h1>Software</h1>
)}
</div>
</div>
</div>
{renderPageActions()}
</div>
<PageDescription content={renderHeaderDescription()} />
</div>
{renderBody()}
{showManageAutomationsModal && softwareConfig && (
<ManageAutomationsModal
router={router}
onCancel={toggleManageAutomationsModal}
onCreateWebhookSubmit={onCreateWebhookSubmit}
togglePreviewPayloadModal={togglePreviewPayloadModal}
togglePreviewTicketModal={togglePreviewTicketModal}
showPreviewPayloadModal={showPreviewPayloadModal}
showPreviewTicketModal={showPreviewTicketModal}
softwareConfig={softwareConfig}
/>
)}
{showAddSoftwareModal && (
<AddSoftwareModal
onExit={() => setShowAddSoftwareModal(false)}
isFreeTier={isFreeTier}
/>
)}
{showSoftwareFiltersModal && (
<SoftwareFiltersModal
onExit={toggleSoftwareFiltersModal}
onSubmit={onApplyVulnFilters}
vulnFilters={softwareVulnFilters}
isPremiumTier={isPremiumTier || false}
/>
)}
</>
</MainContent>
);
};
export default SoftwarePage;