Merge branch 'main' into 14415

This commit is contained in:
Jacob Shandling 2023-12-12 13:13:56 -08:00
commit df1d279a92
112 changed files with 4764 additions and 1829 deletions

View File

@ -32,7 +32,7 @@ of compliant devices. This reflects our commitment to creating user-friendly sys
empathy we share for our users' experience and their need for efficient, straightforward tools.
Learn more about [Fleet's "Verified"
status](https://fleetdm.com/docs/using-fleet/mdm-disk-encryption#step-3-confirm-disk-encryption-is-enforced-and-fleet-is-storing-the-disk-encryption-key).
status](https://fleetdm.com/docs/using-fleet/mdm-disk-encryption#disk-encryption-status).
![Verified Status](../website/assets/images/articles/fleet-4.33.0-verified-status-1425x821@2x.png)

View File

@ -0,0 +1 @@
- Fixed button text wrapping in UI for Settings > Integrations > MDM.

View File

@ -0,0 +1 @@
- Updated manage hosts UI to filter hosts by `software_version_id` and `software_title_id`.

View File

@ -0,0 +1,2 @@
- add new software pages to fleet UI. Includes software titles, software versions, software title
details and software version details.

View File

@ -691,6 +691,7 @@ spec:
versions:
- id: 0
version: 0.0.3
vulnerabilities: null
versions_count: 1
`
@ -741,7 +742,8 @@ spec:
"versions": [
{
"id": 0,
"version": "0.0.3"
"version": "0.0.3",
"vulnerabilities": null
}
]
}

View File

@ -2,30 +2,26 @@
_Available in Fleet Premium_
In Fleet, you can enforce disk encryption on your macOS hosts. Apple calls this [FileVault](https://support.apple.com/en-us/HT204837). If turned on, hosts disk encryption keys will be stored in Fleet.
In Fleet, you can enforce disk encryption for your macOS and Windows hosts.
You can also enforce custom macOS settings. Learn how [here](./MDM-custom-macOS-settings.md).
> Apple calls this [FileVault](https://support.apple.com/en-us/HT204837) and Microsoft calls this [BitLocker](https://learn.microsoft.com/en-us/windows/security/operating-system-security/data-protection/bitlocker/).
When disk encryption is enforced, hosts disk encryption keys will be stored in Fleet.
## Enforce disk encryption
To enforce disk encryption and have Fleet collect the disk encryption key, we will do the following steps:
1. Enforce disk encryption
2. Share migrations with end users
2. Confirm disk encryption is enforced and Fleet is storing the disk encryption key
### Step 1: enforce disk encryption
To enforce disk encryption, choose the "Fleet UI" or "fleetctl" method and follow the steps below.
You can enforce disk encryption in the Fleet UI, with Fleet API, or with the fleetctl command-line interface (CLI).
Fleet UI:
1. In the Fleet UI, head to the **Controls > macOS settings > Disk encryption** page. Users with the maintainer and admin roles can access the settings pages.
1. In Fleet, head to the **Controls > OS settings > Disk encryption** page.
2. Choose which team you want to enforce disk encryption on by selecting the desired team in the teams dropdown in the upper left corner. Teams are available in Fleet Premium.
2. Choose which team you want to enforce disk encryption on by selecting the desired team in the teams dropdown in the upper left corner.
3. Check the box next to **Turn on** and select **Save**.
Fleet API: API documentation is [here](../REST%20API/rest-api.md#update-disk-encryption-enforcement)
`fleetctl` CLI:
1. Choose which team you want to enforce disk encryption on.
@ -41,8 +37,7 @@ spec:
team:
name: Workstations (canary)
mdm:
macos_settings:
enable_disk_encryption: true
enable_disk_encryption: true
...
```
@ -53,28 +48,19 @@ apiVersion: v1
kind: config
spec:
mdm:
macos_settings:
enable_disk_encryption: true
enable_disk_encryption: true
...
```
Learn more about configuration options for hosts that aren't assigned to a team [here](./configuration-files/README.md#organization-settings).
3. Set the `mdm.macos_settings.enable_disk_encryption` configuration option to `true`.
3. Set the `mdm.enable_disk_encryption` configuration option to `true`.
4. Run the `fleetctl apply -f workstations-canary-config.yml` command.
> Fleet auto-configures `DeferForceAtUserLoginMaxBypassAttempts` to `1`, ensuring mandatory disk encryption during new Mac setup.
### Step 2: share migration instructions with your end users
### Disk encryption status
In order to complete the process of encrypting the hard drive and escrowing the key in Fleet, your end users must take action. If the host already had disk encryption turned on, the user will need to input their password. If the host did not already have disk encryption turned on, the user will need to log out or restart their computer.
Share [these guided instructions](./MDM-migration-guide.md#how-to-turn-on-disk-encryption) with your end users.
### Step 3: confirm disk encryption is enforced and Fleet is storing the disk encryption key
In the Fleet UI, head to the **Controls > macOS settings > Disk encryption** tab. You will see a table that shows the status of disk encryption on your hosts.
In the Fleet UI, head to the **Controls > OS settings > Disk encryption** tab. You will see a table that shows the status of disk encryption on your hosts.
* Verified: the host turned disk encryption on and sent their key to Fleet. Fleet verified with osquery. See instructions for viewing the disk encryption key [here](#view-disk-encryption-key).
@ -94,31 +80,23 @@ You can click each status to view the list of hosts for that status.
## View disk encryption key
The disk encryption key allows you to reset a macOS host's password if you don't know it. This way, if you plan to prepare a host for a new employee, you can login to it and erase all its content and settings.
The key can be accessed by Fleet admin, maintainers, and observers. An event is tracked in the activity feed when a user views the key in Fleet.
How to view the disk encryption key:
1. Select a host on the **Hosts** page.
2. On the **Host details** page, select **Actions > Show disk encryption key**.
> Whenever a disk encryption key is viewed, an activity will be logged. To view activity in the Fleet UI, click on the Fleet icon in the top navigation bar and make sure **All teams** is selected in the teams dropdown.
## Migrate macOS hosts
## Reset a macOS host's password using the disk encryption key
When migrating macOS hosts another MDM solution, in order to complete the process of encrypting the hard drive and escrowing the key in Fleet, your end users must take action.
How to reset a macOS host's password using the disk encryption key:
If the host already had disk encryption turned on, the user will need to input their password.
1. Restart the host. If you just unlocked a host that was locked remotely, the host will automatically restart.
If the host did not already have disk encryption turned on, the user will need to log out or restart their computer.
2. On the Mac's login screen, enter the incorrect password three times. After the third failed login attempt, the Mac will display a prompt below the password field with the following message: "If you forgot your password, you can reset it using your Recovery Key." Select the right facing arrow at the end of this prompt.
3. Enter the disk encryption key. Note that Apple calls this "Recovery key." Learn how to find a host's disk encryption key [here](#view-disk-encryption-key).
4. The Mac will display a prompt to reset the password. Reset the password and save this password somewhere safe. If you plan to prepare this Mac for a new employee, you'll need this password to erase all content and settings on the Mac.
Share [these guided instructions](./MDM-migration-guide.md#how-to-turn-on-disk-encryption) with your end users.
<meta name="pageOrderInSection" value="1504">
<meta name="title" value="Disk encryption">
<meta name="description" value="Learn how to enforce disk encryption on macOS hosts and manage encryption keys with Fleet Premium.">
<meta name="description" value="Learn how to enforce disk encryption on macOS and Windows hosts and manage encryption keys with Fleet Premium.">
<meta name="navSection" value="Device management">

View File

@ -36,7 +36,7 @@ Fleet UI:
> Currently, you can only run scripts on macOS and Windows hosts in the Fleet UI. To run a script on a Linux host, use the Fleet API or fleetctl CLI.
Fleet API: API documentation is [here](https://fleetdm.com/docs/rest-api/rest-api#run-script)
Fleet API: API documentation is [here](../REST%20API/rest-api.md#run-script)
fleetctl CLI:

View File

@ -1,4 +1,16 @@
import { ISoftware } from "interfaces/software";
import {
ISoftware,
ISoftwareVersion,
ISoftwareTitle,
ISoftwareVulnerability,
ISoftwareTitleVersion,
} from "interfaces/software";
import {
ISoftwareTitlesResponse,
ISoftwareTitleResponse,
ISoftwareVersionsResponse,
ISoftwareVersionResponse,
} from "services/entities/software";
const DEFAULT_SOFTWARE_MOCK: ISoftware = {
hosts_count: 1,
@ -12,8 +24,125 @@ const DEFAULT_SOFTWARE_MOCK: ISoftware = {
bundle_identifier: "com.app.mock",
};
const createMockSoftware = (overrides?: Partial<ISoftware>): ISoftware => {
export const createMockSoftware = (
overrides?: Partial<ISoftware>
): ISoftware => {
return { ...DEFAULT_SOFTWARE_MOCK, ...overrides };
};
export default createMockSoftware;
const DEFAULT_SOFTWARE_TITLE_VERSION_MOCK = {
id: 1,
version: "1.0.0",
vulnerabilities: ["CVE-2020-0001"],
};
export const createMockSoftwareTitleVersion = (
overrides?: Partial<ISoftwareTitleVersion>
): ISoftwareTitleVersion => {
return { ...DEFAULT_SOFTWARE_TITLE_VERSION_MOCK, ...overrides };
};
const DEFAULT_SOFTWARE_TITLE_MOCK: ISoftwareTitle = {
id: 1,
name: "mock software 1.app",
versions_count: 1,
source: "apps",
hosts_count: 1,
browser: "chrome",
versions: [createMockSoftwareTitleVersion()],
};
export const createMockSoftwareTitle = (
overrides?: Partial<ISoftwareTitle>
): ISoftwareTitle => {
return { ...DEFAULT_SOFTWARE_TITLE_MOCK, ...overrides };
};
const DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK: ISoftwareTitlesResponse = {
counts_updated_at: "2020-01-01T00:00:00.000Z",
count: 1,
software_titles: [createMockSoftwareTitle()],
meta: {
has_next_results: false,
has_previous_results: false,
},
};
export const createMockSoftwareTitlesReponse = (
overrides?: Partial<ISoftwareTitlesResponse>
): ISoftwareTitlesResponse => {
return { ...DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK, ...overrides };
};
const DEFAULT_SOFTWARE_VULNERABILITY_MOCK = {
cve: "CVE-2020-0001",
details_link: "https://test.com",
cvss_score: 9,
epss_probability: 0.8,
cisa_known_exploit: false,
cve_published: "2020-01-01T00:00:00.000Z",
cve_description: "test description",
resolved_in_version: "1.2.3",
};
export const createMockSoftwareVulnerability = (
overrides?: Partial<ISoftwareVulnerability>
): ISoftwareVulnerability => {
return { ...DEFAULT_SOFTWARE_VULNERABILITY_MOCK, ...overrides };
};
const DEFAULT_SOFTWARE_VERSION_MOCK: ISoftwareVersion = {
id: 1,
name: "test.app",
version: "1.2.3",
bundle_identifier: "com.test.Desktop",
source: "test_package",
release: "1",
vendor: "test_vendor",
arch: "x86_64",
generated_cpe: "cpe:test:app:1.2.3",
vulnerabilities: [createMockSoftwareVulnerability()],
hosts_count: 1,
};
export const createMockSoftwareVersion = (
overrides?: Partial<ISoftwareVersion>
): ISoftwareVersion => {
return { ...DEFAULT_SOFTWARE_VERSION_MOCK, ...overrides };
};
const DEFAULT_SOFTWARE_VERSIONS_RESPONSE_MOCK: ISoftwareVersionsResponse = {
counts_updated_at: "2020-01-01T00:00:00.000Z",
count: 1,
software: [createMockSoftwareVersion()],
meta: {
has_next_results: false,
has_previous_results: false,
},
};
export const createMockSoftwareVersionsReponse = (
overrides?: Partial<ISoftwareVersionsResponse>
): ISoftwareVersionsResponse => {
return { ...DEFAULT_SOFTWARE_VERSIONS_RESPONSE_MOCK, ...overrides };
};
const DEFAULT_SOFTWARE_TITLE_RESPONSE = {
software_title: createMockSoftwareTitle(),
};
export const createMockSoftwareTitleResponse = (
overrides?: Partial<ISoftwareTitleResponse>
): ISoftwareTitleResponse => {
return { ...DEFAULT_SOFTWARE_TITLE_RESPONSE, ...overrides };
};
const DEFAULT_SOFTWARE_VERSION_RESPONSE = {
software: createMockSoftwareVersion(),
};
export const createMockSoftwareVersionResponse = (
overrides?: Partial<ISoftwareVersionResponse>
): ISoftwareVersionResponse => {
return { ...DEFAULT_SOFTWARE_VERSION_RESPONSE, ...overrides };
};

View File

@ -95,7 +95,6 @@ const SiteTopNav = ({
isGlobalMaintainer,
isAnyTeamMaintainer,
isNoAccess,
isMdmEnabledAndConfigured, // TODO: confirm
isSandboxMode,
} = useContext(AppContext);
@ -160,9 +159,6 @@ const SiteTopNav = ({
{name}
</span>
</Link>
{/* <div className={`${navItemBaseClass}__link`}>
<span className={`${navItemBaseClass}__name`}>{name}</span>
</div> */}
</li>
);
}

View File

@ -67,7 +67,7 @@ export default (
name: "Software",
location: {
regex: new RegExp(`^${URL_PREFIX}/software/`),
pathname: PATHS.MANAGE_SOFTWARE,
pathname: PATHS.SOFTWARE_TITLES,
},
withParams: { type: "query", names: ["team_id"] },
},

View File

@ -1,5 +1,5 @@
import PropTypes from "prop-types";
import vulnerabilityInterface, { IVulnerability } from "./vulnerability";
import vulnerabilityInterface from "./vulnerability";
export default PropTypes.shape({
type: PropTypes.string,
@ -23,6 +23,8 @@ export interface IGetSoftwareByIdResponse {
software: ISoftware;
}
// TODO: old software interface. replaced with ISoftwareVersion
// check to see if we still need this.
export interface ISoftware {
id: number;
name: string; // e.g., "Figma.app"
@ -30,12 +32,54 @@ export interface ISoftware {
bundle_identifier?: string | null; // e.g., "com.figma.Desktop"
source: string; // e.g., "apps"
generated_cpe: string;
vulnerabilities: IVulnerability[] | null;
vulnerabilities: ISoftwareVulnerability[] | null;
hosts_count?: number;
last_opened_at?: string | null; // e.g., "2021-08-18T15:11:35Z”
installed_paths?: string[];
}
export interface ISoftwareTitleVersion {
id: number;
version: string;
vulnerabilities: string[] | null; // TODO: does this return null or is it omitted?
hosts_count?: number;
}
export interface ISoftwareTitle {
id: number;
name: string;
versions_count: number;
source: string;
hosts_count: number;
versions: ISoftwareTitleVersion[];
browser: string;
}
export interface ISoftwareVulnerability {
cve: string;
details_link: string;
cvss_score?: number | null;
epss_probability?: number | null;
cisa_known_exploit?: boolean | null;
cve_published?: string | null;
cve_description?: string | null;
resolved_in_version?: string | null;
}
export interface ISoftwareVersion {
id: number;
name: string; // e.g., "Figma.app"
version: string; // e.g., "2.1.11"
bundle_identifier?: string; // e.g., "com.figma.Desktop"
source: string; // e.g., "apps"
release: string; // TODO: on software/verions/:id?
vendor: string;
arch: string; // e.g., "x86_64" // TODO: on software/verions/:id?
generated_cpe: string;
vulnerabilities: ISoftwareVulnerability[] | null;
hosts_count?: number;
}
export const TYPE_CONVERSION: Record<string, string> = {
apt_sources: "Package (APT)",
deb_packages: "Package (deb)",
@ -56,7 +100,8 @@ export const TYPE_CONVERSION: Record<string, string> = {
pkg_packages: "Package (pkg)",
} as const;
export const formatSoftwareType = (source: string): string => {
// TODO: update with new software types
export const formatSoftwareType = (source: string) => {
const DICT = TYPE_CONVERSION;
return DICT[source] || "Unknown";
};

View File

@ -4,19 +4,3 @@ export default PropTypes.shape({
cve: PropTypes.string,
details_link: PropTypes.string,
});
export interface IHostsAffected {
id: number;
display_name: string;
url: string;
software_installed_paths?: string[];
}
export interface IVulnerability {
cve: string;
details_link: string;
cvss_score?: number;
epss_probability?: number;
cisa_known_exploit?: boolean;
cve_published?: string;
hosts_affected?: IHostsAffected[];
}

View File

@ -465,11 +465,11 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
};
const onSoftwareTabChange = (index: number) => {
const { MANAGE_SOFTWARE } = paths;
const { SOFTWARE_TITLES } = paths;
setSoftwareNavTabIndex(index);
setSoftwareActionUrl &&
setSoftwareActionUrl(
index === 1 ? `${MANAGE_SOFTWARE}?vulnerable=true` : MANAGE_SOFTWARE
index === 1 ? `${SOFTWARE_TITLES}?vulnerable=true` : SOFTWARE_TITLES
);
};

View File

@ -11,7 +11,7 @@ import TabsWrapper from "components/TabsWrapper";
import TableContainer from "components/TableContainer";
import TableDataError from "components/DataError";
import Spinner from "components/Spinner";
import EmptySoftwareTable from "pages/software/components/EmptySoftwareTable";
import EmptySoftwareTable from "pages/SoftwarePage/components/EmptySoftwareTable";
import generateTableHeaders from "./SoftwareTableConfig";

View File

@ -0,0 +1,380 @@
import React, { useCallback, useContext, useState } from "react";
import { InjectedRouter } from "react-router";
import { useQuery } from "react-query";
import { Tab, TabList, Tabs } from "react-tabs";
import PATHS from "router/paths";
import {
IConfig,
CONFIG_DEFAULT_RECENT_VULNERABILITY_MAX_AGE_IN_DAYS,
} from "interfaces/config";
import {
IJiraIntegration,
IZendeskIntegration,
IIntegrations,
} from "interfaces/integration";
import { ITeamConfig } from "interfaces/team";
import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook";
import configAPI from "services/entities/config";
import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import useTeamIdParam from "hooks/useTeamIdParam";
import Button from "components/buttons/Button";
import MainContent from "components/MainContent";
import TeamsDropdown from "components/TeamsDropdown";
import TabsWrapper from "components/TabsWrapper";
import ManageAutomationsModal from "./components/ManageAutomationsModal";
interface ISoftwareSubNavItem {
name: string;
pathname: string;
}
const softwareSubNav: ISoftwareSubNavItem[] = [
{
name: "Software",
pathname: PATHS.SOFTWARE_TITLES,
},
{
name: "Versions",
pathname: PATHS.SOFTWARE_VERSIONS,
},
];
const getTabIndex = (path: string): number => {
return softwareSubNav.findIndex((navItem) => {
// tab stays highlighted for paths that start with same pathname
return path.startsWith(navItem.pathname);
});
};
// default values for query params used on this page if not provided
const DEFAULT_SORT_DIRECTION = "desc";
const DEFAULT_SORT_HEADER = "hosts_count";
const DEFAULT_PAGE_SIZE = 20;
const DEFAULT_PAGE = 0;
const baseClass = "software-page";
interface ISoftwareAutomations {
webhook_settings: {
vulnerabilities_webhook: IWebhookSoftwareVulnerabilities;
};
integrations: {
jira: IJiraIntegration[];
zendesk: IZendeskIntegration[];
};
}
interface ISoftwareConfigQueryKey {
scope: string;
teamId?: number;
}
interface ISoftwarePageProps {
children: JSX.Element;
location: {
pathname: string;
search: string;
query: {
team_id?: string;
vulnerable?: string;
page?: string;
query?: string;
order_key?: string;
order_direction?: "asc" | "desc";
};
hash?: string;
};
router: InjectedRouter; // v3
}
const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
const {
config: globalConfig,
isFreeTier,
isGlobalAdmin,
isGlobalMaintainer,
isOnGlobalTeam,
isPremiumTier,
isSandboxMode,
} = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
const queryParams = location.query;
// initial values for query params used on this page
const query = queryParams && queryParams.query ? queryParams.query : "";
const sortHeader =
queryParams && queryParams.order_key
? queryParams.order_key
: DEFAULT_SORT_HEADER;
const sortDirection =
queryParams?.order_direction === undefined
? DEFAULT_SORT_DIRECTION
: queryParams.order_direction;
const page =
queryParams && queryParams.page
? parseInt(queryParams.page, 10)
: DEFAULT_PAGE;
const showVulnerableSoftware =
queryParams !== undefined && queryParams.vulnerable === "true";
const [showManageAutomationsModal, setShowManageAutomationsModal] = useState(
false
);
const [showPreviewPayloadModal, setShowPreviewPayloadModal] = useState(false);
const [showPreviewTicketModal, setShowPreviewTicketModal] = useState(false);
const {
currentTeamId,
isAnyTeamSelected,
isRouteOk,
teamIdForApi,
userTeams,
handleTeamChange,
} = useTeamIdParam({
location,
router,
includeAllTeams: true,
includeNoTeam: false,
});
// softwareConfig is either the global config or the team config of the
// currently selected team depending on the page team context selected
// by the user.
const {
data: softwareConfig,
error: softwareConfigError,
isFetching: isFetchingSoftwareConfig,
refetch: refetchSoftwareConfig,
} = useQuery<
IConfig | ILoadTeamResponse,
Error,
IConfig | ITeamConfig,
ISoftwareConfigQueryKey[]
>(
[{ scope: "softwareConfig", teamId: teamIdForApi }],
({ queryKey }) => {
const { teamId } = queryKey[0];
return teamId ? teamsAPI.load(teamId) : configAPI.loadAll();
},
{
enabled: isRouteOk,
select: (data) => ("team" in data ? data.team : data),
}
);
// TODO: move into manage automations modal
const vulnWebhookSettings =
softwareConfig?.webhook_settings?.vulnerabilities_webhook;
const isVulnWebhookEnabled = !!vulnWebhookSettings?.enable_vulnerabilities_webhook;
const isVulnIntegrationEnabled = (integrations?: IIntegrations) => {
return (
!!integrations?.jira?.some((j) => j.enable_software_vulnerabilities) ||
!!integrations?.zendesk?.some((z) => z.enable_software_vulnerabilities)
);
};
// TODO: move into manage automations modal
const isAnyVulnAutomationEnabled =
isVulnWebhookEnabled ||
isVulnIntegrationEnabled(softwareConfig?.integrations);
// TODO: move into manage automations modal
const recentVulnerabilityMaxAge = (() => {
let maxAgeInNanoseconds: number | undefined;
if (softwareConfig && "vulnerabilities" in softwareConfig) {
maxAgeInNanoseconds =
softwareConfig.vulnerabilities.recent_vulnerability_max_age;
} else {
maxAgeInNanoseconds =
globalConfig?.vulnerabilities.recent_vulnerability_max_age;
}
return maxAgeInNanoseconds
? Math.round(maxAgeInNanoseconds / 86400000000000) // convert from nanoseconds to days
: CONFIG_DEFAULT_RECENT_VULNERABILITY_MAX_AGE_IN_DAYS;
})();
const isSoftwareConfigLoaded =
!isFetchingSoftwareConfig && !softwareConfigError && !!softwareConfig;
const canManageAutomations =
isGlobalAdmin && (!isPremiumTier || !isAnyTeamSelected);
const toggleManageAutomationsModal = useCallback(() => {
setShowManageAutomationsModal(!showManageAutomationsModal);
}, [setShowManageAutomationsModal, showManageAutomationsModal]);
const togglePreviewPayloadModal = useCallback(() => {
setShowPreviewPayloadModal(!showPreviewPayloadModal);
}, [setShowPreviewPayloadModal, showPreviewPayloadModal]);
const togglePreviewTicketModal = useCallback(() => {
setShowPreviewTicketModal(!showPreviewTicketModal);
}, [setShowPreviewTicketModal, showPreviewTicketModal]);
// TODO: move into manage automations modal
const onCreateWebhookSubmit = async (
configSoftwareAutomations: ISoftwareAutomations
) => {
try {
const request = configAPI.update(configSoftwareAutomations);
await request.then(() => {
renderFlash(
"success",
"Successfully updated vulnerability automations."
);
refetchSoftwareConfig();
});
} catch {
renderFlash(
"error",
"Could not update vulnerability automations. Please try again."
);
} finally {
toggleManageAutomationsModal();
}
};
const onTeamChange = useCallback(
(teamId: number) => {
handleTeamChange(teamId);
// TODO: reset page to 0 when changing teams
},
[handleTeamChange]
);
const navigateToNav = useCallback(
(i: number): void => {
const navPath = softwareSubNav[i].pathname;
router.replace(
navPath.concat(location?.search || "").concat(location?.hash || "")
);
},
[location, router]
);
const renderTitle = () => {
return (
<>
{isFreeTier && <h1>Software</h1>}
{isPremiumTier &&
userTeams &&
(userTeams.length > 1 || isOnGlobalTeam) && (
<TeamsDropdown
currentUserTeams={userTeams || []}
selectedTeamId={currentTeamId}
onChange={onTeamChange}
isSandboxMode={isSandboxMode}
/>
)}
{isPremiumTier &&
!isOnGlobalTeam &&
userTeams &&
userTeams.length === 1 && <h1>{userTeams[0].name}</h1>}
</>
);
};
const renderHeaderDescription = () => {
return (
<p>
Search for installed software{" "}
{(isGlobalAdmin || isGlobalMaintainer) &&
(!isPremiumTier || !isAnyTeamSelected) &&
"and manage automations for detected vulnerabilities (CVEs)"}{" "}
on{" "}
<b>
{isPremiumTier && isAnyTeamSelected
? "all hosts assigned to this team"
: "all of your hosts"}
</b>
.
</p>
);
};
const renderBody = () => {
return (
<div>
<TabsWrapper>
<Tabs
selectedIndex={getTabIndex(location?.pathname || "")}
onSelect={navigateToNav}
>
<TabList>
{softwareSubNav.map((navItem) => {
return (
<Tab key={navItem.name} data-text={navItem.name}>
{navItem.name}
</Tab>
);
})}
</TabList>
</Tabs>
</TabsWrapper>
{React.cloneElement(children, {
router,
isSoftwareEnabled: Boolean(
softwareConfig?.features?.enable_software_inventory
),
query,
// NOTE: may move this lower in tree if we need different values for different pages
perPage: DEFAULT_PAGE_SIZE,
orderDirection: sortDirection,
orderKey: sortHeader,
showVulnerableSoftware,
currentPage: page,
teamId: teamIdForApi,
})}
</div>
);
};
return (
<MainContent>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__header-wrap`}>
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__text`}>
<div className={`${baseClass}__title`}>{renderTitle()}</div>
</div>
</div>
{canManageAutomations && isSoftwareConfigLoaded && (
<Button
onClick={toggleManageAutomationsModal}
className={`${baseClass}__manage-automations button`}
variant="brand"
>
<span>Manage automations</span>
</Button>
)}
</div>
<div className={`${baseClass}__description`}>
{renderHeaderDescription()}
</div>
{renderBody()}
{showManageAutomationsModal && (
<ManageAutomationsModal
onCancel={toggleManageAutomationsModal}
onCreateWebhookSubmit={onCreateWebhookSubmit}
togglePreviewPayloadModal={togglePreviewPayloadModal}
togglePreviewTicketModal={togglePreviewTicketModal}
showPreviewPayloadModal={showPreviewPayloadModal}
showPreviewTicketModal={showPreviewTicketModal}
softwareVulnerabilityAutomationEnabled={isAnyVulnAutomationEnabled}
softwareVulnerabilityWebhookEnabled={isVulnWebhookEnabled}
currentDestinationUrl={vulnWebhookSettings?.destination_url || ""}
recentVulnerabilityMaxAge={recentVulnerabilityMaxAge}
/>
)}
</div>
</MainContent>
);
};
export default SoftwarePage;

View File

@ -0,0 +1,82 @@
import React, { useContext } from "react";
import { RouteComponentProps } from "react-router";
import { useQuery } from "react-query";
import { AppContext } from "context/app";
import { ISoftwareTitle } from "interfaces/software";
import softwareAPI, {
ISoftwareTitleResponse,
} from "services/entities/software";
import MainContent from "components/MainContent";
import TableDataError from "components/DataError";
import SoftwareDetailsSummary from "../components/SoftwareDetailsSummary";
import SoftwareTitleDetailsTable from "./SoftwareTitleDetailsTable";
const baseClass = "software-title-details-page";
interface ISoftwareTitleDetailsRouteParams {
id: string;
}
type ISoftwareTitleDetailsPageProps = RouteComponentProps<
undefined,
ISoftwareTitleDetailsRouteParams
>;
const SoftwareTitleDetailsPage = ({
router,
routeParams,
}: ISoftwareTitleDetailsPageProps) => {
// TODO: handle non integer values
const softwareId = parseInt(routeParams.id, 10);
const {
data: softwareTitle,
isLoading: isSoftwareTitleLoading,
isError: isSoftwareTitleError,
} = useQuery<ISoftwareTitleResponse, Error, ISoftwareTitle>(
["softwareById", softwareId],
() => softwareAPI.getSoftwareTitle(softwareId),
{
select: (data) => data.software_title,
}
);
if (!softwareTitle) {
return null;
}
return (
<MainContent className={baseClass}>
{isSoftwareTitleError ? (
<TableDataError className={`${baseClass}__table-error`} />
) : (
<>
<SoftwareDetailsSummary
id={softwareId}
title={softwareTitle.name}
type={softwareTitle.source}
versions={softwareTitle.versions.length}
hosts={softwareTitle.hosts_count}
queryParam="software_title_id"
name={softwareTitle.name}
source={softwareTitle.source}
/>
{/* TODO: can we use Card here for card styles */}
<div className={`${baseClass}__versions-section`}>
<h2>Versions</h2>
<SoftwareTitleDetailsTable
router={router}
data={softwareTitle.versions}
isLoading={isSoftwareTitleLoading}
/>
</div>
</>
)}
</MainContent>
);
};
export default SoftwareTitleDetailsPage;

View File

@ -0,0 +1,70 @@
import React, { useMemo } from "react";
import { InjectedRouter } from "react-router";
import { ISoftwareTitleVersion } from "interfaces/software";
import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants";
import TableContainer from "components/TableContainer";
import EmptyTable from "components/EmptyTable";
import CustomLink from "components/CustomLink";
import generateSoftwareTitleDetailsTableConfig from "./SoftwareTitleDetailsTableConfig";
const DEFAULT_SORT_HEADER = "hosts_count";
const DEFAULT_SORT_DIRECTION = "desc";
const baseClass = "software-title-details-table";
const NoVersionsDetected = (): JSX.Element => {
return (
<EmptyTable
header="No versions detected for this software item."
info={
<>
Expecting to see versions?{" "}
<CustomLink
url={GITHUB_NEW_ISSUE_LINK}
text="File an issue on GitHub"
newTab
/>
</>
}
/>
);
};
interface ISoftwareTitleDetailsTableProps {
router: InjectedRouter;
data: ISoftwareTitleVersion[];
isLoading: boolean;
}
const SoftwareTitleDetailsTable = ({
router,
data,
isLoading,
}: ISoftwareTitleDetailsTableProps) => {
const softwareTableHeaders = useMemo(
() => generateSoftwareTitleDetailsTableConfig(router),
[router]
);
return (
<TableContainer
className={baseClass}
resultsTitle={data.length === 1 ? "version" : "versions"}
columns={softwareTableHeaders}
data={data}
isLoading={isLoading}
emptyComponent={NoVersionsDetected}
showMarkAllPages={false}
isAllPagesSelected={false}
defaultSortHeader={DEFAULT_SORT_HEADER}
defaultSortDirection={DEFAULT_SORT_DIRECTION}
disablePagination
// TODO: add row click handler
/>
);
};
export default SoftwareTitleDetailsTable;

View File

@ -0,0 +1,111 @@
import React from "react";
import { InjectedRouter } from "react-router";
import {
ISoftwareTitleVersion,
ISoftwareVulnerability,
} from "interfaces/software";
import PATHS from "router/paths";
import TextCell from "components/TableContainer/DataTable/TextCell";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import LinkCell from "components/TableContainer/DataTable/LinkCell";
import VulnerabilitiesCell from "../../components/VulnerabilitiesCell";
interface ICellProps {
cell: {
value: number | string | ISoftwareVulnerability[];
};
row: {
original: ISoftwareTitleVersion;
};
}
interface IVersionCellProps extends ICellProps {
cell: {
value: string;
};
}
interface INumberCellProps extends ICellProps {
cell: {
value: number;
};
}
interface IVulnCellProps extends ICellProps {
cell: {
value: ISoftwareVulnerability[];
};
}
const generateSoftwareTitleDetailsTableConfig = (router: InjectedRouter) => {
const tableHeaders = [
{
title: "Version",
Header: "Version",
disableSortBy: true,
accessor: "version",
Cell: (cellProps: IVersionCellProps): JSX.Element => {
const { id } = cellProps.row.original;
const onClickSoftware = (e: React.MouseEvent) => {
// Allows for button to be clickable in a clickable row
e.stopPropagation();
router?.push(PATHS.SOFTWARE_VERSION_DETAILS(id.toString()));
};
// TODO: make only text clickable
return (
<LinkCell
className="name-link"
path={PATHS.SOFTWARE_VERSION_DETAILS(id.toString())}
customOnClick={onClickSoftware}
value={cellProps.cell.value}
/>
);
},
},
{
title: "Vulnerabilities",
Header: "Vulnerabilities",
disableSortBy: true,
// the "vulnerabilities" accessor is used but the data is actually coming
// from the version attribute. We do this as we already have a "versions"
// attribute used for the "Version" column and we cannot reuse. This is a
// limitation of react-table.
// With the versions data, we can sum up the vulnerabilities to get the
// total number of vulnerabilities for the software title
accessor: "vulnerabilities",
Cell: (cellProps: IVulnCellProps): JSX.Element => (
<VulnerabilitiesCell vulnerabilities={cellProps.cell.value} />
// TODO: tooltip
),
},
{
title: "Hosts",
Header: "Hosts",
disableSortBy: true,
accessor: "hosts_count",
Cell: (cellProps: INumberCellProps): JSX.Element => (
<span className="hosts-cell__wrapper">
<span className="hosts-cell__count">
<TextCell value={cellProps.cell.value} />
</span>
<span className="hosts-cell__link">
<ViewAllHostsLink
queryParams={{
software_version_id: cellProps.row.original.id,
}}
className="software-link"
/>
</span>
</span>
),
},
];
return tableHeaders;
};
export default generateSoftwareTitleDetailsTableConfig;

View File

@ -0,0 +1,13 @@
.software-title-details-table {
.hosts-cell__wrapper {
display: flex;
align-items: center;
justify-content: space-between;
.hosts-cell__link {
display: flex;
white-space: nowrap;
}
}
}

View File

@ -0,0 +1 @@
export { default } from "./SoftwareTitleDetailsTable";

View File

@ -0,0 +1,33 @@
.software-title-details-page {
background-color: $ui-off-white;
display: flex;
flex-direction: column;
gap: $pad-medium;
&__versions-section {
background-color: $core-white;
padding: $pad-xxlarge;
border: 1px solid $ui-fleet-black-10;
border-radius: $border-radius-xxlarge;
box-shadow: $box-shadow;
h2 {
margin: 0;
font-size: $medium;
}
}
// for showing and hiding software link on hover
tr {
.software-link {
opacity: 0;
transition: opacity 250ms;
}
&:hover {
.software-link {
opacity: 1;
}
}
}
}

View File

@ -0,0 +1 @@
export { default } from "./SoftwareTitleDetailsPage";

View File

@ -0,0 +1,303 @@
import React, { useCallback, useContext, useMemo } from "react";
import { InjectedRouter } from "react-router";
import { useQuery } from "react-query";
import { Row } from "react-table";
import PATHS from "router/paths";
import softwareAPI, {
ISoftwareApiParams,
ISoftwareTitlesResponse,
} from "services/entities/software";
import { AppContext } from "context/app";
import {
GITHUB_NEW_ISSUE_LINK,
VULNERABLE_DROPDOWN_OPTIONS,
} from "utilities/constants";
import { getNextLocationPath } from "utilities/helpers";
import { buildQueryStringFromParams } from "utilities/url";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import TableDataError from "components/DataError";
import TableContainer from "components/TableContainer";
import CustomLink from "components/CustomLink";
import LastUpdatedText from "components/LastUpdatedText";
import { ITableQueryData } from "components/TableContainer/TableContainer";
import EmptySoftwareTable from "../components/EmptySoftwareTable";
import generateSoftwareTitlesTableHeaders from "./SoftwareTitlesTableConfig";
const baseClass = "software-titles";
interface IRowProps extends Row {
original: {
id?: number;
};
}
interface ISoftwareTitlesQueryKey extends ISoftwareApiParams {
scope: "software-titles";
}
interface ISoftwareTitlesProps {
router: InjectedRouter;
isSoftwareEnabled: boolean;
query: string;
perPage: number;
orderDirection: "asc" | "desc";
orderKey: string;
showVulnerableSoftware: boolean;
currentPage: number;
teamId?: number;
}
const SoftwareTitles = ({
router,
isSoftwareEnabled,
query,
perPage,
orderDirection,
orderKey,
showVulnerableSoftware,
currentPage,
teamId,
}: ISoftwareTitlesProps) => {
const { isSandboxMode, noSandboxHosts } = useContext(AppContext);
// request to get software data
const {
data: softwareData,
isLoading: isSoftwareLoading,
isError: isSoftwareError,
} = useQuery<
ISoftwareTitlesResponse,
Error,
ISoftwareTitlesResponse,
ISoftwareTitlesQueryKey[]
>(
[
{
scope: "software-titles",
page: currentPage,
perPage,
query,
orderDirection,
orderKey,
teamId,
vulnerable: showVulnerableSoftware,
},
],
({ queryKey }) => softwareAPI.getSoftwareTitles(queryKey[0]),
{
// stale time can be adjusted if fresher data is desired based on
// software inventory interval
staleTime: 30000,
}
);
// determines if a user be able to search in the table
const searchable =
isSoftwareEnabled &&
(!!softwareData?.software_titles || query !== "" || showVulnerableSoftware);
const softwareTableHeaders = useMemo(
() => generateSoftwareTitlesTableHeaders(router, teamId),
[router, teamId]
);
const handleVulnFilterDropdownChange = (isFilterVulnerable: string) => {
router.replace(
getNextLocationPath({
pathPrefix: PATHS.SOFTWARE_TITLES,
routeTemplate: "",
queryParams: {
query,
teamId,
orderDirection,
orderKey,
vulnerable: isFilterVulnerable,
page: 0, // resets page index
},
})
);
};
const handleRowSelect = (row: IRowProps) => {
const hostsBySoftwareParams = {
software_title_id: row.original.id,
team_id: teamId,
};
const path = `${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams(
hostsBySoftwareParams
)}`;
router.push(path);
};
const determineQueryParamChange = useCallback(
(newTableQuery: ITableQueryData) => {
const changedEntry = Object.entries(newTableQuery).find(([key, val]) => {
switch (key) {
case "searchQuery":
return val !== query;
case "sortDirection":
return val !== orderDirection;
case "sortHeader":
return val !== orderKey;
case "vulnerable":
return val !== showVulnerableSoftware.toString();
case "pageIndex":
return val !== currentPage;
default:
return false;
}
});
return changedEntry?.[0] ?? "";
},
[currentPage, orderDirection, orderKey, query, showVulnerableSoftware]
);
const generateNewQueryParams = useCallback(
(newTableQuery: ITableQueryData, changedParam: string) => {
return {
query: newTableQuery.searchQuery,
team_id: teamId,
order_direction: newTableQuery.sortDirection,
order_key: newTableQuery.sortHeader,
vulnerable: showVulnerableSoftware.toString(),
page: changedParam === "pageIndex" ? newTableQuery.pageIndex : 0,
};
},
[showVulnerableSoftware, teamId]
);
// NOTE: this is called once on initial render and every time the query changes
const onQueryChange = useCallback(
(newTableQuery: ITableQueryData) => {
// we want to determine which query param has changed in order to
// reset the page index to 0 if any other param has changed.
const changedParam = determineQueryParamChange(newTableQuery);
// if nothing has changed, don't update the route. this can happen when
// this handler is called on the inital render.
if (changedParam === "") return;
const newRoute = getNextLocationPath({
pathPrefix: PATHS.SOFTWARE_TITLES,
routeTemplate: "",
queryParams: generateNewQueryParams(newTableQuery, changedParam),
});
router.replace(newRoute);
},
[determineQueryParamChange, generateNewQueryParams, router]
);
const getItemsCountText = () => {
const count = softwareData?.count;
if (!softwareData || !count) return "";
return count === 1 ? `${count} item` : `${count} items`;
};
const getLastUpdatedText = () => {
if (!softwareData || !softwareData.counts_updated_at) return "";
return (
<LastUpdatedText
lastUpdatedAt={softwareData.counts_updated_at}
whatToRetrieve={"software"}
/>
);
};
const renderSoftwareCount = () => {
const itemText = getItemsCountText();
const lastUpdatedText = getLastUpdatedText();
if (!itemText) return null;
return (
<div className={`${baseClass}__count`}>
<span>{itemText}</span>
{lastUpdatedText}
</div>
);
};
const renderVulnFilterDropdown = () => {
return (
<Dropdown
value={showVulnerableSoftware}
className={`${baseClass}__vuln_dropdown`}
options={VULNERABLE_DROPDOWN_OPTIONS}
searchable={false}
onChange={handleVulnFilterDropdownChange}
tableFilterDropdown
/>
);
};
const renderTableFooter = () => {
return (
<div>
Seeing unexpected software or vulnerabilities?{" "}
<CustomLink
url={GITHUB_NEW_ISSUE_LINK}
text="File an issue on GitHub"
newTab
/>
</div>
);
};
if (isSoftwareError) {
return <TableDataError className={`${baseClass}__table-error`} />;
}
return (
<div className={baseClass}>
<TableContainer
columns={softwareTableHeaders}
data={softwareData?.software_titles || []}
isLoading={isSoftwareLoading}
resultsTitle={"items"}
emptyComponent={() => (
<EmptySoftwareTable
isSoftwareDisabled={!isSoftwareEnabled}
isFilterVulnerable={showVulnerableSoftware}
isSandboxMode={isSandboxMode}
isCollectingSoftware={false} // TODO: update with new API
isSearching={query !== ""}
noSandboxHosts={noSandboxHosts}
/>
)}
defaultSortHeader={orderKey}
defaultSortDirection={orderDirection}
defaultPageIndex={currentPage}
defaultSearchQuery={query}
manualSortBy
pageSize={perPage}
showMarkAllPages={false}
isAllPagesSelected={false}
disableNextPage={!softwareData?.meta.has_next_results}
searchable={searchable}
inputPlaceHolder="Search by name or vulnerabilities (CVEs)"
onQueryChange={onQueryChange}
// additionalQueries serves as a trigger for the useDeepEffect hook
// to fire onQueryChange for events happeing outside of
// the TableContainer.
additionalQueries={showVulnerableSoftware ? "vulnerable" : ""}
customControl={searchable ? renderVulnFilterDropdown : undefined}
stackControls
renderCount={renderSoftwareCount}
renderFooter={renderTableFooter}
disableMultiRowSelect
onSelectSingleRow={handleRowSelect}
/>
</div>
);
};
export default SoftwareTitles;

View File

@ -0,0 +1,185 @@
import React from "react";
import { Column } from "react-table";
import { InjectedRouter } from "react-router";
import {
ISoftwareTitleVersion,
ISoftwareTitle,
formatSoftwareType,
} from "interfaces/software";
import PATHS from "router/paths";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
import TextCell from "components/TableContainer/DataTable/TextCell";
import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import VersionCell from "../components/VersionCell";
import VulnerabilitiesCell from "../components/VulnerabilitiesCell";
import SoftwareIcon from "../components/icons/SoftwareIcon";
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
interface ICellProps {
cell: {
value: number | string | ISoftwareTitleVersion[];
};
row: {
original: ISoftwareTitle;
};
}
interface IStringCellProps extends ICellProps {
cell: {
value: string;
};
}
interface IVersionCellProps extends ICellProps {
cell: {
value: ISoftwareTitleVersion[];
};
row: {
original: ISoftwareTitle;
};
}
interface INumberCellProps extends ICellProps {
cell: {
value: number;
};
}
interface IVulnCellProps extends ICellProps {
cell: {
value: ISoftwareTitleVersion[];
};
}
interface IHeaderProps {
column: {
title: string;
isSortedDesc: boolean;
};
}
const getVulnerabilities = (versions: ISoftwareTitleVersion[]) => {
const vulnerabilities = versions.reduce((acc: string[], currentVersion) => {
if (
currentVersion.vulnerabilities &&
currentVersion.vulnerabilities.length !== 0
) {
acc.push(...currentVersion.vulnerabilities);
}
return acc;
}, []);
return vulnerabilities;
};
const generateTableHeaders = (
router: InjectedRouter,
teamId?: number
): Column[] => {
const softwareTableHeaders = [
{
title: "Name",
Header: (cellProps: IHeaderProps): JSX.Element => (
<HeaderCell
value={cellProps.column.title}
isSortedDesc={cellProps.column.isSortedDesc}
/>
),
disableSortBy: false,
accessor: "name",
Cell: (cellProps: IStringCellProps): JSX.Element => {
const { id, name, source } = cellProps.row.original;
const onClickSoftware = (e: React.MouseEvent) => {
// Allows for button to be clickable in a clickable row
e.stopPropagation();
router?.push(PATHS.SOFTWARE_TITLE_DETAILS(id.toString()));
};
return (
<LinkCell
path={PATHS.SOFTWARE_TITLE_DETAILS(id.toString())}
customOnClick={onClickSoftware}
value={
<>
<SoftwareIcon name={name} source={source} />
<span className="software-name">{name}</span>
</>
}
/>
);
},
sortType: "caseInsensitive",
},
{
title: "Version",
Header: "Version",
disableSortBy: true,
accessor: "versions",
Cell: (cellProps: IVersionCellProps): JSX.Element => (
<VersionCell versions={cellProps.cell.value} />
),
},
{
title: "Type",
Header: "Type",
disableSortBy: true,
accessor: "source",
Cell: (cellProps: IStringCellProps): JSX.Element => (
<TextCell formatter={formatSoftwareType} value={cellProps.cell.value} />
),
},
// the "vulnerabilities" accessor is used but the data is actually coming
// from the version attribute. We do this as we already have a "versions"
// attribute used for the "Version" column and we cannot reuse. This is a
// limitation of react-table.
// With the versions data, we can sum up the vulnerabilities to get the
// total number of vulnerabilities for the software title
{
title: "Vulnerabilities",
Header: "Vulnerabilities",
disableSortBy: true,
accessor: "vulnerabilities",
Cell: (cellProps: IVulnCellProps): JSX.Element => {
const vulnerabilities = getVulnerabilities(
cellProps.row.original.versions
);
return <VulnerabilitiesCell vulnerabilities={vulnerabilities} />;
},
},
{
title: "Hosts",
Header: (cellProps: IHeaderProps): JSX.Element => (
<HeaderCell
value={cellProps.column.title}
disableSortBy={false}
isSortedDesc={cellProps.column.isSortedDesc}
/>
),
disableSortBy: false,
accessor: "hosts_count",
Cell: (cellProps: INumberCellProps): JSX.Element => (
<span className="hosts-cell__wrapper">
<span className="hosts-cell__count">
<TextCell value={cellProps.cell.value} />
</span>
<span className="hosts-cell__link">
<ViewAllHostsLink
queryParams={{
software_title_id: cellProps.row.original.id,
team_id: teamId, // TODO: do we need team id here?
}}
className="software-link"
/>
</span>
</span>
),
},
];
return softwareTableHeaders;
};
export default generateTableHeaders;

View File

@ -0,0 +1,165 @@
.software-titles {
margin-top: $pad-xxlarge;
&__count {
display: flex;
gap: 12px;
}
&__vuln_dropdown {
.Select-menu-outer {
width: 250px;
max-height: 310px;
.Select-menu {
max-height: none;
}
}
.Select-value {
padding-left: $pad-medium;
padding-right: $pad-medium;
}
.dropdown__custom-value-label {
width: 155px; // Override 105px for longer text options
}
}
.table-container {
&__header {
flex-direction: column-reverse; // Search bar on top
margin-bottom: $pad-medium;
@media (min-width: $break-md) {
flex-direction: row;
}
}
&__header-left {
flex-direction: row; // Filter dropdown aligned with count
.controls {
.form-field--dropdown {
margin: 0;
}
}
}
&__search-input,
&__search {
width: 100%; // Search bar across entire table
.input-icon-field__input {
width: 100%;
}
@media (min-width: $break-md) {
width: auto;
.input-icon-field__input {
width: 375px;
}
}
}
&__data-table-block {
.data-table-block {
.data-table__table {
// for showing and hiding "view all hosts" link on hover
tr {
.software-link {
opacity: 0;
transition: opacity 250ms;
}
&:hover {
.software-link {
opacity: 1;
}
}
}
thead {
.name__header {
width: $col-md;
}
.hosts_count__header {
width: auto;
border-right: 0;
}
@media (min-width: $break-lg) {
// expand the width of version header at larger screen sizes
.versions__header {
width: $col-md;
}
}
}
tbody {
.name__cell {
max-width: $col-md;
// Tooltip does not get cut off
.children-wrapper {
overflow: initial;
}
// ellipsis for software name
.software-name {
overflow: hidden;
text-wrap: nowrap;
text-overflow: ellipsis;
}
}
.link-cell {
display: flex;
align-items: center;
gap: $pad-small;
}
.hosts_count__cell {
.hosts-cell__wrapper {
display: flex;
align-items: center;
justify-content: space-between;
.hosts-cell__link {
display: flex;
white-space: nowrap;
}
}
}
@media (min-width: $break-sm) {
.name__cell {
max-width: $col-lg;
}
}
@media (min-width: $break-lg) {
.versions__cell {
width: $col-md;
}
}
}
}
}
}
}
// needed to handle overflow of the table data on small screens
.data-table {
&__wrapper {
overflow-x: auto;
}
}
&__table-error {
margin-top: $pad-xxxlarge;
}
}

View File

@ -0,0 +1 @@
export { default } from "./SoftwareTitles";

View File

@ -0,0 +1,125 @@
import React, { useContext, useMemo } from "react";
import { useQuery } from "react-query";
import { RouteComponentProps } from "react-router";
import softwareAPI, {
ISoftwareVersionResponse,
} from "services/entities/software";
import { ISoftwareVersion } from "interfaces/software";
import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants";
import { AppContext } from "context/app";
import MainContent from "components/MainContent";
import TableContainer from "components/TableContainer";
import CustomLink from "components/CustomLink";
import EmptyTable from "components/EmptyTable";
import TableDataError from "components/DataError";
import generateSoftwareVersionDetailsTableConfig from "./SoftwareVersionDetailsTableConfig";
import SoftwareDetailsSummary from "../components/SoftwareDetailsSummary";
const baseClass = "software-version-details-page";
interface ISoftwareVersionDetailsRouteParams {
id: string;
}
type ISoftwareTitleDetailsPageProps = RouteComponentProps<
undefined,
ISoftwareVersionDetailsRouteParams
>;
const NoVulnsDetected = (): JSX.Element => {
return (
<EmptyTable
header="No vulnerabilities detected for this software item."
info={
<>
Expecting to see vulnerabilities?{" "}
<CustomLink
url={GITHUB_NEW_ISSUE_LINK}
text="File an issue on GitHub"
newTab
/>
</>
}
/>
);
};
const SoftwareVersionDetailsPage = ({
routeParams,
}: ISoftwareTitleDetailsPageProps) => {
const versionId = parseInt(routeParams.id, 10);
const { isPremiumTier, isSandboxMode, filteredSoftwarePath } = useContext(
AppContext
);
const {
data: softwareVersion,
isLoading: isSoftwareVersionLoading,
isError: isSoftwareVersionError,
} = useQuery<ISoftwareVersionResponse, Error, ISoftwareVersion>(
["software-version", versionId],
() => softwareAPI.getSoftwareVersion(versionId),
{
select: (data) => data.software,
}
);
const tableHeaders = useMemo(
() =>
generateSoftwareVersionDetailsTableConfig(
Boolean(isPremiumTier),
Boolean(isSandboxMode)
),
[isPremiumTier, isSandboxMode]
);
if (!softwareVersion) {
return null;
}
return (
<MainContent className={baseClass}>
{isSoftwareVersionError ? (
<TableDataError className={`${baseClass}__table-error`} />
) : (
<>
<SoftwareDetailsSummary
id={softwareVersion.id}
title={`${softwareVersion.name}, ${softwareVersion.version}`}
type={softwareVersion.source}
hosts={softwareVersion.hosts_count ?? 0}
queryParam="software_version_id"
name={softwareVersion.name}
source={softwareVersion.source}
/>
<div className={`${baseClass}__vulnerabilities-section`}>
<h2 className="section__header">Vulnerabilities</h2>
{softwareVersion?.vulnerabilities?.length ? (
<div className="vuln-table">
<TableContainer
columns={tableHeaders}
data={softwareVersion.vulnerabilities}
defaultSortHeader={isPremiumTier ? "epss_probability" : "cve"}
defaultSortDirection={"desc"}
emptyComponent={NoVulnsDetected}
isAllPagesSelected={false}
isLoading={isSoftwareVersionLoading}
isClientSidePagination
pageSize={20}
resultsTitle={"vulnerabilities"}
showMarkAllPages={false}
/>
</div>
) : (
<NoVulnsDetected />
)}
</div>
</>
)}
</MainContent>
);
};
export default SoftwareVersionDetailsPage;

View File

@ -1,6 +1,5 @@
import React from "react";
import { IVulnerability } from "interfaces/vulnerability";
import { formatFloatAsPercentage } from "utilities/helpers";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
@ -10,6 +9,7 @@ import TooltipWrapper from "components/TooltipWrapper";
import CustomLink from "components/CustomLink";
import { HumanTimeDiffWithDateTip } from "components/HumanTimeDiffWithDateTip";
import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip";
import { ISoftwareVulnerability } from "interfaces/software";
interface IHeaderProps {
column: {
@ -22,7 +22,7 @@ interface ICellProps {
value: number | string | string[];
};
row: {
original: IVulnerability;
original: ISoftwareVulnerability;
index: number;
};
}
@ -62,7 +62,7 @@ const formatSeverity = (float: number | null) => {
return `${severity} (${float.toFixed(1)})`;
};
const generateVulnTableHeaders = (
const generateSoftwareVersionDetailsTableConfig = (
isPremiumTier: boolean,
isSandboxMode: boolean
): IDataColumn[] => {
@ -106,11 +106,11 @@ const generateVulnTableHeaders = (
);
return (
<>
{isSandboxMode && <PremiumFeatureIconWithTooltip />}
<HeaderCell
value={titleWithToolTip}
isSortedDesc={headerProps.column.isSortedDesc}
/>
{isSandboxMode && <PremiumFeatureIconWithTooltip />}
</>
);
},
@ -140,11 +140,11 @@ const generateVulnTableHeaders = (
);
return (
<>
{isSandboxMode && <PremiumFeatureIconWithTooltip />}
<HeaderCell
value={titleWithToolTip}
isSortedDesc={headerProps.column.isSortedDesc}
/>
{isSandboxMode && <PremiumFeatureIconWithTooltip />}
</>
);
},
@ -173,11 +173,11 @@ const generateVulnTableHeaders = (
);
return (
<>
{isSandboxMode && <PremiumFeatureIconWithTooltip />}
<HeaderCell
value={titleWithToolTip}
isSortedDesc={headerProps.column.isSortedDesc}
/>
{isSandboxMode && <PremiumFeatureIconWithTooltip />}
</>
);
},
@ -205,11 +205,11 @@ const generateVulnTableHeaders = (
);
return (
<>
{isSandboxMode && <PremiumFeatureIconWithTooltip />}
<HeaderCell
value={titleWithToolTip}
isSortedDesc={headerProps.column.isSortedDesc}
/>
{isSandboxMode && <PremiumFeatureIconWithTooltip />}
</>
);
},
@ -228,4 +228,4 @@ const generateVulnTableHeaders = (
return isPremiumTier ? tableHeaders.concat(premiumHeaders) : tableHeaders;
};
export default generateVulnTableHeaders;
export default generateSoftwareVersionDetailsTableConfig;

View File

@ -0,0 +1,25 @@
.software-version-details-page {
background-color: $ui-off-white;
display: flex;
flex-direction: column;
gap: $pad-medium;
&__vulnerabilities-section {
background-color: $core-white;
padding: $pad-xxlarge;
border: 1px solid $ui-fleet-black-10;
border-radius: $border-radius-xxlarge;
box-shadow: $box-shadow;
h2 {
margin: 0;
font-size: $medium;
}
}
// used to position header text with premium icon correctly
.column-header {
display: flex;
gap: $pad-small;
}
}

View File

@ -0,0 +1 @@
export { default } from "./SoftwareVersionDetailsPage";

View File

@ -0,0 +1,318 @@
import React, { useCallback, useContext, useMemo } from "react";
import { InjectedRouter } from "react-router";
import { useQuery } from "react-query";
import { Row } from "react-table";
import PATHS from "router/paths";
import softwareAPI, {
ISoftwareApiParams,
ISoftwareVersionsResponse,
} from "services/entities/software";
import { AppContext } from "context/app";
import {
GITHUB_NEW_ISSUE_LINK,
VULNERABLE_DROPDOWN_OPTIONS,
} from "utilities/constants";
import { getNextLocationPath } from "utilities/helpers";
import { buildQueryStringFromParams } from "utilities/url";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import TableDataError from "components/DataError";
import TableContainer from "components/TableContainer";
import CustomLink from "components/CustomLink";
import LastUpdatedText from "components/LastUpdatedText";
import { ITableQueryData } from "components/TableContainer/TableContainer";
import EmptySoftwareTable from "../components/EmptySoftwareTable";
import generateSoftwareVersionsTableHeaders from "./SoftwareVersionsTableConfig";
const baseClass = "software-versions";
interface IRowProps extends Row {
original: {
id?: number;
};
}
interface ISoftwareVersionsQueryKey extends ISoftwareApiParams {
scope: "software-versions";
}
interface ISoftwareVersionsProps {
router: InjectedRouter;
isSoftwareEnabled: boolean;
query: string;
perPage: number;
orderDirection: "asc" | "desc";
orderKey: string;
showVulnerableSoftware: boolean;
currentPage: number;
teamId?: number;
}
const SoftwareVersions = ({
router,
isSoftwareEnabled,
query,
perPage,
orderDirection,
orderKey,
showVulnerableSoftware,
currentPage,
teamId,
}: ISoftwareVersionsProps) => {
const { isSandboxMode, noSandboxHosts, isPremiumTier } = useContext(
AppContext
);
// request to get software versions data
const {
data: softwareVersionsData,
isLoading: isSoftwareVersionsLoading,
isError: isSoftwareVersionsError,
} = useQuery<
ISoftwareVersionsResponse,
Error,
ISoftwareVersionsResponse,
ISoftwareVersionsQueryKey[]
>(
[
{
scope: "software-versions",
page: currentPage,
perPage,
query,
orderDirection,
orderKey,
teamId,
vulnerable: showVulnerableSoftware,
},
],
({ queryKey }) => softwareAPI.getSoftwareVersions(queryKey[0]),
{
keepPreviousData: true,
// stale time can be adjusted if fresher data is desired based on
// software inventory interval
staleTime: 30000,
}
);
// determines if a user be able to search in the table
const searchable =
isSoftwareEnabled &&
(!!softwareVersionsData?.software ||
query !== "" ||
showVulnerableSoftware);
const softwareTableHeaders = useMemo(
() =>
generateSoftwareVersionsTableHeaders(
router,
isPremiumTier,
isSandboxMode,
teamId
),
[isPremiumTier, isSandboxMode, router, teamId]
);
// TODO: figure out why this is not working
const handleVulnFilterDropdownChange = (isFilterVulnerable: string) => {
router.replace(
getNextLocationPath({
pathPrefix: PATHS.SOFTWARE_VERSIONS,
routeTemplate: "",
queryParams: {
query,
teamId,
orderDirection,
orderKey,
vulnerable: isFilterVulnerable,
page: 0, // resets page index
},
})
);
};
const handleRowSelect = (row: IRowProps) => {
const hostsBySoftwareParams = {
software_version_id: row.original.id,
team_id: teamId,
};
const path = `${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams(
hostsBySoftwareParams
)}`;
router.push(path);
};
const determineQueryParamChange = useCallback(
(newTableQuery: ITableQueryData) => {
const changedEntry = Object.entries(newTableQuery).find(([key, val]) => {
switch (key) {
case "searchQuery":
return val !== query;
case "sortDirection":
return val !== orderDirection;
case "sortHeader":
return val !== orderKey;
case "vulnerable":
return val !== showVulnerableSoftware.toString();
case "pageIndex":
return val !== currentPage;
default:
return false;
}
});
return changedEntry?.[0] ?? "";
},
[currentPage, orderDirection, orderKey, query, showVulnerableSoftware]
);
const generateNewQueryParams = useCallback(
(newTableQuery: ITableQueryData) => {
return {
query: newTableQuery.searchQuery,
team_id: teamId,
order_direction: newTableQuery.sortDirection,
order_key: newTableQuery.sortHeader,
vulnerable: showVulnerableSoftware.toString(),
page: newTableQuery.pageIndex,
};
},
[showVulnerableSoftware, teamId]
);
// NOTE: this is called once on initial render and every time the query changes
const onQueryChange = useCallback(
(newTableQuery: ITableQueryData) => {
// we want to determine which query param has changed in order to
// reset the page index to 0 if any other param has changed.
const changedParam = determineQueryParamChange(newTableQuery);
// if nothing has changed, don't update the route. this can happen when
// this handler is called on the inital render.
if (changedParam === "") return;
const newRoute = getNextLocationPath({
pathPrefix: PATHS.SOFTWARE_VERSIONS,
routeTemplate: "",
queryParams: generateNewQueryParams(newTableQuery),
});
router.replace(newRoute);
},
[determineQueryParamChange, generateNewQueryParams, router]
);
const getItemsCountText = () => {
const count = softwareVersionsData?.count;
if (!softwareVersionsData || !count) return "";
return count === 1 ? `${count} item` : `${count} items`;
};
const getLastUpdatedText = () => {
if (!softwareVersionsData || !softwareVersionsData.counts_updated_at)
return "";
return (
<LastUpdatedText
lastUpdatedAt={softwareVersionsData.counts_updated_at}
whatToRetrieve={"software"}
/>
);
};
const renderSoftwareCount = () => {
const itemText = getItemsCountText();
const lastUpdatedText = getLastUpdatedText();
if (!itemText) return null;
return (
<div className={`${baseClass}__count`}>
<span>{itemText}</span>
{lastUpdatedText}
</div>
);
};
const renderVulnFilterDropdown = () => {
return (
<Dropdown
value={showVulnerableSoftware}
className={`${baseClass}__vuln_dropdown`}
options={VULNERABLE_DROPDOWN_OPTIONS}
searchable={false}
onChange={handleVulnFilterDropdownChange}
tableFilterDropdown
/>
);
};
const renderTableFooter = () => {
return (
<div>
Seeing unexpected software or vulnerabilities?{" "}
<CustomLink
url={GITHUB_NEW_ISSUE_LINK}
text="File an issue on GitHub"
newTab
/>
</div>
);
};
if (isSoftwareVersionsError) {
return <TableDataError className={`${baseClass}__table-error`} />;
}
return (
<div className={baseClass}>
<div className={baseClass}>
<TableContainer
columns={softwareTableHeaders}
data={softwareVersionsData?.software || []}
isLoading={isSoftwareVersionsLoading}
resultsTitle={"items"}
emptyComponent={() => (
<EmptySoftwareTable
isSoftwareDisabled={!isSoftwareEnabled}
isFilterVulnerable={showVulnerableSoftware}
isSandboxMode={isSandboxMode}
isCollectingSoftware={false} // TODO: update with new API
isSearching={query !== ""}
noSandboxHosts={noSandboxHosts}
/>
)}
defaultSortHeader={orderKey}
defaultSortDirection={orderDirection}
defaultPageIndex={currentPage}
defaultSearchQuery={query}
manualSortBy
pageSize={perPage}
showMarkAllPages={false}
isAllPagesSelected={false}
disableNextPage={!softwareVersionsData?.meta.has_next_results}
searchable={searchable}
inputPlaceHolder="Search by name or vulnerabilities (CVEs)"
onQueryChange={onQueryChange}
// additionalQueries serves as a trigger for the useDeepEffect hook
// to fire onQueryChange for events happeing outside of
// the TableContainer.
additionalQueries={showVulnerableSoftware ? "vulnerable" : ""}
customControl={searchable ? renderVulnFilterDropdown : undefined}
stackControls
renderCount={renderSoftwareCount}
renderFooter={renderTableFooter}
disableMultiRowSelect
onSelectSingleRow={handleRowSelect}
/>
</div>
</div>
);
};
export default SoftwareVersions;

View File

@ -0,0 +1,160 @@
import React from "react";
import { Column } from "react-table";
import { InjectedRouter } from "react-router";
import {
formatSoftwareType,
ISoftwareVersion,
ISoftwareVulnerability,
} from "interfaces/software";
import PATHS from "router/paths";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
import TextCell from "components/TableContainer/DataTable/TextCell";
import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import VulnerabilitiesCell from "../components/VulnerabilitiesCell";
import SoftwareIcon from "../components/icons/SoftwareIcon";
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
interface ICellProps {
cell: {
value: number | string | ISoftwareVulnerability[];
};
row: {
original: ISoftwareVersion;
};
}
interface IStringCellProps extends ICellProps {
cell: {
value: string;
};
}
interface IVersionCellProps extends ICellProps {
cell: {
value: string;
};
}
interface INumberCellProps extends ICellProps {
cell: {
value: number;
};
}
interface IVulnCellProps extends ICellProps {
cell: {
value: ISoftwareVulnerability[];
};
}
interface IHeaderProps {
column: {
title: string;
isSortedDesc: boolean;
};
}
const generateTableHeaders = (
router: InjectedRouter,
isPremiumTier?: boolean,
isSandboxMode?: boolean,
teamId?: number
): Column[] => {
const softwareTableHeaders = [
{
title: "Name",
Header: (cellProps: IHeaderProps): JSX.Element => (
<HeaderCell
value={cellProps.column.title}
isSortedDesc={cellProps.column.isSortedDesc}
/>
),
disableSortBy: false,
accessor: "name",
Cell: (cellProps: IStringCellProps): JSX.Element => {
const { id, name, source } = cellProps.row.original;
const onClickSoftware = (e: React.MouseEvent) => {
// Allows for button to be clickable in a clickable row
e.stopPropagation();
router?.push(PATHS.SOFTWARE_VERSION_DETAILS(id.toString()));
};
return (
<LinkCell
path={PATHS.SOFTWARE_VERSION_DETAILS(id.toString())}
customOnClick={onClickSoftware}
value={
<>
<SoftwareIcon name={name} source={source} />
<span className="software-name">{name}</span>
</>
}
/>
);
},
sortType: "caseInsensitive",
},
{
title: "Version",
Header: "Version",
disableSortBy: true,
accessor: "version",
Cell: (cellProps: IVersionCellProps): JSX.Element => (
<TextCell value={cellProps.cell.value} />
),
},
{
title: "Type",
Header: "Type",
disableSortBy: true,
accessor: "source",
Cell: (cellProps: IStringCellProps): JSX.Element => (
<TextCell formatter={formatSoftwareType} value={cellProps.cell.value} />
),
},
{
title: "Vulnerabilities",
Header: "Vulnerabilities",
disableSortBy: true,
accessor: "vulnerabilities",
Cell: (cellProps: IVulnCellProps): JSX.Element => (
<VulnerabilitiesCell vulnerabilities={cellProps.cell.value} />
),
},
{
title: "Hosts",
Header: (cellProps: IHeaderProps): JSX.Element => (
<HeaderCell
value={cellProps.column.title}
disableSortBy={false}
isSortedDesc={cellProps.column.isSortedDesc}
/>
),
disableSortBy: false,
accessor: "hosts_count",
Cell: (cellProps: INumberCellProps): JSX.Element => (
<span className="hosts-cell__wrapper">
<span className="hosts-cell__count">
<TextCell value={cellProps.cell.value} />
</span>
<span className="hosts-cell__link">
<ViewAllHostsLink
queryParams={{
software_version_id: cellProps.row.original.id,
team_id: teamId, // TODO: do we need team id here?
}}
className="software-link"
/>
</span>
</span>
),
},
];
return softwareTableHeaders;
};
export default generateTableHeaders;

View File

@ -0,0 +1,166 @@
.software-versions {
margin-top: $pad-xxlarge;
&__count {
display: flex;
gap: 12px;
}
&__vuln_dropdown {
.Select-menu-outer {
width: 250px;
max-height: 310px;
.Select-menu {
max-height: none;
}
}
.Select-value {
padding-left: $pad-medium;
padding-right: $pad-medium;
}
.dropdown__custom-value-label {
width: 155px; // Override 105px for longer text options
}
}
.table-container {
&__header {
flex-direction: column-reverse; // Search bar on top
margin-bottom: $pad-medium;
@media (min-width: $break-md) {
flex-direction: row;
}
}
&__header-left {
flex-direction: row; // Filter dropdown aligned with count
.controls {
.form-field--dropdown {
margin: 0;
}
}
}
&__search-input,
&__search {
width: 100%; // Search bar across entire table
.input-icon-field__input {
width: 100%;
}
@media (min-width: $break-md) {
width: auto;
.input-icon-field__input {
width: 375px;
}
}
}
&__data-table-block {
.data-table-block {
.data-table__table {
// for showing and hiding "view all hosts" link on hover
tr {
.software-link {
opacity: 0;
transition: opacity 250ms;
}
&:hover {
.software-link {
opacity: 1;
}
}
}
thead {
.name__header {
width: $col-md;
}
.hosts_count__header {
width: auto;
border-right: 0;
}
@media (min-width: $break-lg) {
// expand the width of version header at larger screen sizes
.version__header {
width: $col-md;
}
}
}
tbody {
.name__cell {
max-width: $col-md;
// Tooltip does not get cut off
.children-wrapper {
overflow: initial;
}
// ellipsis for software name
.software-name {
overflow: hidden;
text-wrap: nowrap;
text-overflow: ellipsis;
}
}
.link-cell {
display: flex;
align-items: center;
gap: $pad-small;
}
.hosts_count__cell {
.hosts-cell__wrapper {
display: flex;
align-items: center;
justify-content: space-between;
.hosts-cell__link {
display: flex;
white-space: nowrap;
}
}
}
@media (min-width: $break-sm) {
.name__cell {
max-width: $col-lg;
}
}
@media (min-width: $break-lg) {
.version__cell {
width: $col-md;
}
}
}
}
}
}
}
// needed to handle overflow of the table data on small screens
.data-table {
&__wrapper {
overflow-x: auto;
}
}
&__table-error {
margin-top: $pad-xxxlarge;
}
}

View File

@ -0,0 +1 @@
export { default } from "./SoftwareVersions";

View File

@ -0,0 +1,59 @@
.software-page {
&__header-wrap {
display: flex;
align-items: center;
justify-content: space-between;
height: 38px;
.button-wrap {
display: flex;
justify-content: flex-end;
min-width: 266px;
}
}
&__manage-automations {
padding: $pad-small $pad-medium;
}
&__header {
display: flex;
align-items: center;
.form-field {
margin-bottom: 0;
}
}
&__text {
margin-right: $pad-large;
}
&__title {
font-size: $large;
}
&__description {
margin: 0;
margin-bottom: $pad-large;
max-width: 75%;
@media (min-width: $break-md) {
max-width: none;
}
h2 {
text-transform: uppercase;
color: $core-fleet-black;
font-weight: $regular;
font-size: $small;
}
p {
color: $ui-fleet-black-75;
margin: 0;
font-size: $x-small;
font-style: italic;
}
}
}

View File

@ -1,4 +1,5 @@
// This component is used on DashboardPage.tsx > Software.tsx, Host Details/Device User > Software.tsx, and ManageSoftwarePage.tsx
// This component is used on DashboardPage.tsx > Software.tsx,
// Host Details / Device User > Software.tsx, and SoftwarePage.tsx
import React from "react";

View File

@ -0,0 +1 @@
export { default } from "./EmptySoftwareTable";

View File

@ -1,8 +1,9 @@
import React, { useContext } from "react";
import { syntaxHighlight } from "utilities/helpers";
import { AppContext } from "context/app";
import { IVulnerability } from "interfaces/vulnerability";
import { syntaxHighlight } from "utilities/helpers";
import { ISoftwareVulnerability } from "interfaces/software";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
@ -12,9 +13,21 @@ const baseClass = "preview-data-modal";
interface IPreviewPayloadModalProps {
onCancel: () => void;
}
interface IHostsAffected {
id: number;
display_name: string;
url: string;
software_installed_paths?: string[];
}
type IWebhookPayload = {
hosts_affected?: IHostsAffected[] | null;
} & ISoftwareVulnerability;
interface IJsonPayload {
timestamp: string;
vulnerability: IVulnerability;
vulnerability: IWebhookPayload;
}
const PreviewPayloadModal = ({

View File

@ -6,10 +6,10 @@ import Modal from "components/Modal";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
import JiraPreview from "../../../../../../assets/images/jira-vuln-software-preview-400x517@2x.png";
import ZendeskPreview from "../../../../../../assets/images/zendesk-vuln-software-preview-400x455@2x.png";
import JiraPreviewPremium from "../../../../../../assets/images/jira-vuln-software-preview-premium-400x517@2x.png";
import ZendeskPreviewPremium from "../../../../../../assets/images/zendesk-vuln-software-preview-premium-400x455@2x.png";
import JiraPreview from "../../../../../assets/images/jira-vuln-software-preview-400x517@2x.png";
import ZendeskPreview from "../../../../../assets/images/zendesk-vuln-software-preview-400x455@2x.png";
import JiraPreviewPremium from "../../../../../assets/images/jira-vuln-software-preview-premium-400x517@2x.png";
import ZendeskPreviewPremium from "../../../../../assets/images/zendesk-vuln-software-preview-premium-400x455@2x.png";
const baseClass = "preview-ticket-modal";

View File

@ -0,0 +1,69 @@
import ViewAllHostsLink from "components/ViewAllHostsLink";
import React from "react";
import SoftwareIcon from "../icons/SoftwareIcon";
const baseClass = "software-details-summary";
interface IDescriptionSetProps {
title: string;
value: React.ReactNode;
}
// TODO: move to frontend/components
const DataSet = ({ title, value }: IDescriptionSetProps) => {
return (
<div className={`${baseClass}__data-set`}>
<dt>{title}</dt>
<dd>{value}</dd>
</div>
);
};
interface ISoftwareDetailsSummaryProps {
id: number;
title: string;
type: string;
hosts: number;
/** The query param name that will be added when user clicks on "View all hosts" link */
queryParam: string;
name?: string;
source?: string;
versions?: number;
}
const SoftwareDetailsSummary = ({
id,
title,
type,
hosts,
queryParam,
name,
source,
versions,
}: ISoftwareDetailsSummaryProps) => {
return (
<div className={baseClass}>
<SoftwareIcon name={name} source={source} size="large" />
<dl className={`${baseClass}__info`}>
<h1>{title}</h1>
<dl className={`${baseClass}__description-list`}>
<DataSet
title="Type"
// value={formatSoftwareType(software.source)} TODO: format value
value={type}
/>
{versions && <DataSet title="Versions" value={versions} />}
<DataSet title="Hosts" value={hosts === 0 ? "---" : hosts} />
</dl>
</dl>
<div>
<ViewAllHostsLink
queryParams={{ [queryParam]: id }}
className={`${baseClass}__hosts-link`}
/>
</div>
</div>
);
};
export default SoftwareDetailsSummary;

View File

@ -0,0 +1,40 @@
.software-details-summary {
background-color: $core-white;
padding: $pad-xxlarge;
border: 1px solid $ui-fleet-black-10;
border-radius: $border-radius-xxlarge;
box-shadow: $box-shadow;
display: flex;
gap: $pad-medium;
&__icon {
width: 96px;
height: 96px;
border: 1px solid #E2E4EA;
border-radius: 8px;
}
&__info {
flex-grow: 1;
}
h1 {
font-size: $pad-large;
font-weight: bold;
margin-bottom: $pad-medium;
}
&__description-list {
display: flex;
gap: $pad-xxlarge;
}
&__data-set {
font-size: $x-small;
dt {
font-weight: $bold;
}
}
}

View File

@ -0,0 +1 @@
export { default } from "./SoftwareDetailsSummary";

View File

@ -0,0 +1,67 @@
import React from "react";
import { uniqueId } from "lodash";
import { ISoftwareTitleVersion } from "interfaces/software";
import TextCell from "components/TableContainer/DataTable/TextCell";
import ReactTooltip from "react-tooltip";
const baseClass = "version-cell";
const generateText = (versions: ISoftwareTitleVersion[]) => {
const text =
versions.length !== 1 ? `${versions.length} versions` : versions[0].version;
return <TextCell value={text} greyed={versions.length !== 1} />;
};
const generateTooltip = (
versions: ISoftwareTitleVersion[],
tooltipId: string
) => {
if (versions.length <= 1) {
return null;
}
const versionNames = versions.map((version) => version.version);
return (
<ReactTooltip
effect="solid"
backgroundColor="#3e4771"
id={tooltipId}
data-html
>
<p className={`${baseClass}__versions`}>{versionNames.join(", ")}</p>
</ReactTooltip>
);
};
interface IVersionCellProps {
versions: ISoftwareTitleVersion[];
}
const VersionCell = ({ versions }: IVersionCellProps) => {
const tooltipId = uniqueId();
// only one version, no need for tooltip
const cellText = generateText(versions);
if (versions.length <= 1) {
return <>{cellText}</>;
}
const versionTooltip = generateTooltip(versions, tooltipId);
return (
<>
<div
className={`${baseClass}__version-text-with-tooltip`}
data-tip
data-for={tooltipId}
>
{cellText}
</div>
{versionTooltip}
</>
);
};
export default VersionCell;

View File

@ -0,0 +1,10 @@
.version-cell {
&__version-text-with-tooltip {
display: inline-block;
}
&__versions {
margin: 0;
}
}

View File

@ -0,0 +1 @@
export { default } from "./VersionCell";

View File

@ -0,0 +1,114 @@
import React from "react";
import { uniqueId } from "lodash";
import ReactTooltip from "react-tooltip";
import TextCell from "components/TableContainer/DataTable/TextCell";
import { ISoftwareVulnerability } from "interfaces/software";
const NUM_VULNERABILITIES_IN_TOOLTIP = 3;
const baseClass = "vulnerabilities-cell";
const generateCell = (
vulnerabilities: ISoftwareVulnerability[] | string[] | null
) => {
if (vulnerabilities === null) {
return <TextCell value="---" greyed />;
}
let text = "";
let isGrayed = true;
if (vulnerabilities.length === 0) {
text = "---";
} else if (vulnerabilities.length === 1) {
isGrayed = false;
text =
typeof vulnerabilities[0] === "string"
? vulnerabilities[0]
: vulnerabilities[0].cve;
} else {
text = `${vulnerabilities.length} vulnerabilities`;
}
return <TextCell value={text} greyed={isGrayed} />;
};
const getName = (vulnerabiltiy: ISoftwareVulnerability | string) => {
return typeof vulnerabiltiy === "string" ? vulnerabiltiy : vulnerabiltiy.cve;
};
const condenseVulnerabilities = (
vulnerabilities: ISoftwareVulnerability[] | string[]
) => {
const condensed =
(vulnerabilities?.length &&
vulnerabilities
.slice(-NUM_VULNERABILITIES_IN_TOOLTIP)
.map(getName)
.reverse()) ||
[];
return vulnerabilities.length > NUM_VULNERABILITIES_IN_TOOLTIP
? condensed.concat(
`+${vulnerabilities.length - NUM_VULNERABILITIES_IN_TOOLTIP} more`
)
: condensed;
};
const generateTooltip = (
vulnerabilities: ISoftwareVulnerability[] | string[],
tooltipId: string
) => {
if (vulnerabilities.length <= 1) {
return null;
}
const condensedVulnerabilties = condenseVulnerabilities(vulnerabilities);
return (
<ReactTooltip
effect="solid"
backgroundColor="#3e4771"
id={tooltipId}
data-html
>
<ul className={`${baseClass}__vulnerability-list`}>
{condensedVulnerabilties.map((vulnerability) => {
return <li>&bull; {vulnerability}</li>;
})}
</ul>
</ReactTooltip>
);
};
interface IVulnerabilitiesCellProps {
vulnerabilities: ISoftwareVulnerability[] | string[] | null;
}
const VulnerabilitiesCell = ({
vulnerabilities,
}: IVulnerabilitiesCellProps) => {
const tooltipId = uniqueId();
// only one vulnerability, no need for tooltip
const cell = generateCell(vulnerabilities);
if (vulnerabilities === null || vulnerabilities.length <= 1) {
return <>{cell}</>;
}
const vulnerabilityTooltip = generateTooltip(vulnerabilities, tooltipId);
return (
<>
<div
className={`${baseClass}__vulnerability-text-with-tooltip`}
data-tip
data-for={tooltipId}
>
{cell}
</div>
{vulnerabilityTooltip}
</>
);
};
export default VulnerabilitiesCell;

View File

@ -0,0 +1,12 @@
.vulnerabilities-cell {
&__vulnerability-text-with-tooltip {
display: inline-block;
}
&__vulnerability-list {
margin: 0;
padding: 0;
list-style: none;
text-align: left;
}
}

View File

@ -0,0 +1 @@
export { default } from "./VulnerabilitiesCell";

View File

@ -0,0 +1,17 @@
import React from "react";
import type { SVGProps } from "react";
const AcrobatReader = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" {...props}>
<path
fill="#B30B00"
d="M0 8a8 8 0 0 1 8-8h16a8 8 0 0 1 8 8v16a8 8 0 0 1-8 8H8a8 8 0 0 1-8-8z"
/>
<path
fill="#fff"
d="M25.189 18.169c-1.494-1.639-5.566-.922-6.55-.82-1.394-1.433-2.378-3.059-2.776-3.673.498-1.536.896-3.264.896-4.902 0-1.536-.598-3.06-2.179-3.06-.598 0-1.095.308-1.394.82-.698 1.228-.399 3.673.697 6.22-.598 1.844-1.594 4.596-2.777 6.733-1.594.615-5.067 2.24-5.366 4.083-.1.512.1 1.127.498 1.434.398.41.896.512 1.394.512 2.08 0 4.171-2.957 5.666-5.62 1.195-.409 3.075-1.023 4.968-1.33 2.179 2.048 4.17 2.342 5.167 2.342 1.395 0 1.893-.614 2.08-1.126.261-.487.074-1.204-.324-1.613m-1.395 1.024c-.1.41-.598.819-1.494.614-1.096-.307-2.08-.819-2.876-1.536.697-.102 2.378-.307 3.573-.102.399.102.897.41.797 1.024m-9.637-12.25a.546.546 0 0 1 .498-.307c.498 0 .598.614.598 1.126-.1 1.23-.3 2.548-.698 3.674-.797-2.24-.697-3.878-.398-4.493m-.1 11.533c.498-.922 1.096-2.65 1.295-3.264.498.922 1.395 1.946 1.793 2.445.1-.09-1.693.307-3.088.819m-3.374 2.355c-1.382 2.24-2.677 3.674-3.474 3.674-.1 0-.299 0-.398-.103-.1-.204-.2-.41-.1-.614.1-.82 1.693-1.946 3.972-2.957"
/>
</svg>
);
export default AcrobatReader;

View File

@ -0,0 +1,71 @@
import React from "react";
import type { SVGProps } from "react";
const Chrome = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" {...props}>
<path
fill="#fff"
stroke="#E2E4EA"
d="M.5 8A7.5 7.5 0 0 1 8 .5h16A7.5 7.5 0 0 1 31.5 8v16a7.5 7.5 0 0 1-7.5 7.5H8A7.5 7.5 0 0 1 .5 24z"
/>
<g clipPath="url(#Name=chrome_svg__a)">
<path fill="#fff" d="M16 21.997a6 6 0 1 0 0-12 6 6 0 0 0 0 12" />
<path
fill="url(#Name=chrome_svg__b)"
d="M16 10h10.39a11.997 11.997 0 0 0-20.781.002L10.804 19l.005-.001A5.992 5.992 0 0 1 16 10"
/>
<path
fill="#1A73E8"
d="M16 20.75a4.75 4.75 0 1 0 0-9.5 4.75 4.75 0 0 0 0 9.5"
/>
<path
fill="url(#Name=chrome_svg__c)"
d="M21.196 19.002 16 28a11.997 11.997 0 0 0 10.39-17.998H16l-.002.004a5.993 5.993 0 0 1 5.198 8.996"
/>
<path
fill="url(#Name=chrome_svg__d)"
d="M10.804 19.002 5.61 10.003A11.997 11.997 0 0 0 16.001 28l5.195-8.998-.003-.004a5.992 5.992 0 0 1-10.389.004"
/>
</g>
<defs>
<linearGradient
id="Name=chrome_svg__b"
x1={5.609}
x2={26.391}
y1={11.5}
y2={11.5}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#D93025" />
<stop offset={1} stopColor="#EA4335" />
</linearGradient>
<linearGradient
id="Name=chrome_svg__c"
x1={14.361}
x2={24.752}
y1={27.84}
y2={9.842}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FCC934" />
<stop offset={1} stopColor="#FBBC04" />
</linearGradient>
<linearGradient
id="Name=chrome_svg__d"
x1={17.299}
x2={6.908}
y1={27.251}
y2={9.253}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#1E8E3E" />
<stop offset={1} stopColor="#34A853" />
</linearGradient>
<clipPath id="Name=chrome_svg__a">
<path fill="#fff" d="M4 4h24v24H4z" />
</clipPath>
</defs>
</svg>
);
export default Chrome;

View File

@ -0,0 +1,70 @@
import React from "react";
import type { SVGProps } from "react";
const Excel = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" {...props}>
<path
fill="#fff"
stroke="#E2E4EA"
d="M.5 8A7.5 7.5 0 0 1 8 .5h16A7.5 7.5 0 0 1 31.5 8v16a7.5 7.5 0 0 1-7.5 7.5H8A7.5 7.5 0 0 1 .5 24z"
/>
<path
fill="#185C37"
d="M17.814 15.704 9.117 14.17v11.322a.937.937 0 0 0 .937.936H25.06a.936.936 0 0 0 .939-.936V21.32z"
/>
<path
fill="#21A366"
d="M17.814 6h-7.76a.937.937 0 0 0-.937.936v4.171l8.697 5.107 4.605 1.532L26 16.214v-5.107z"
/>
<path fill="#107C41" d="M9.117 11.107h8.697v5.107H9.117z" />
<path
fill="#000"
d="M15.341 10.086H9.117v12.768h6.224a.943.943 0 0 0 .938-.936V11.022a.943.943 0 0 0-.938-.936"
opacity={0.1}
/>
<path
fill="#000"
d="M14.83 10.596H9.116v12.768h5.712a.943.943 0 0 0 .939-.936V11.532a.943.943 0 0 0-.939-.936"
opacity={0.2}
/>
<path
fill="#000"
d="M14.83 10.596H9.116v11.747h5.712a.943.943 0 0 0 .939-.936v-9.875a.943.943 0 0 0-.939-.936"
opacity={0.2}
/>
<path
fill="#000"
d="M14.318 10.596H9.117v11.747h5.201a.943.943 0 0 0 .938-.936v-9.875a.943.943 0 0 0-.938-.936"
opacity={0.2}
/>
<path
fill="url(#Name=excel_svg__a)"
d="M4.938 10.596h9.38a.938.938 0 0 1 .938.936v9.364a.937.937 0 0 1-.938.936h-9.38A.935.935 0 0 1 4 20.896v-9.364a.936.936 0 0 1 .938-.936"
/>
<path
fill="#fff"
d="m6.907 19.257 1.973-3.051-1.807-3.035h1.451l.986 1.943c.091.184.157.32.187.412h.014c.064-.148.132-.29.204-.429l1.054-1.923h1.336l-1.854 3.018 1.901 3.068h-1.421l-1.14-2.13a1.85 1.85 0 0 1-.134-.287h-.019c-.033.097-.077.19-.132.276l-1.173 2.138z"
/>
<path
fill="#33C481"
d="M25.062 6h-7.248v5.107H26V6.936A.937.937 0 0 0 25.062 6"
/>
<path fill="#107C41" d="M17.814 16.214H26v5.107h-8.186z" />
<defs>
<linearGradient
id="Name=excel_svg__a"
x1={5.96}
x2={13.297}
y1={9.861}
y2={22.568}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#18884F" />
<stop offset={0.5} stopColor="#117E43" />
<stop offset={1} stopColor="#0B6631" />
</linearGradient>
</defs>
</svg>
);
export default Excel;

View File

@ -0,0 +1,20 @@
import React from "react";
import type { SVGProps } from "react";
const Extension = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" {...props}>
<path
fill="#F9FAFC"
stroke="#E2E4EA"
d="M.5 8A7.5 7.5 0 0 1 8 .5h16A7.5 7.5 0 0 1 31.5 8v16a7.5 7.5 0 0 1-7.5 7.5H8A7.5 7.5 0 0 1 .5 24z"
/>
<path
fill="#515774"
fillRule="evenodd"
d="M8.586 7.586A2 2 0 0 1 10 7h3c.527 0 1.044.18 1.432.568.388.388.568.905.568 1.432v2h2V9a2 2 0 0 1 2-2h3c.527 0 1.044.18 1.432.568.388.388.568.905.568 1.432v2a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V13a2 2 0 0 1 2-2V9a2 2 0 0 1 .586-1.414M22 11V9h-3v2zm-4 2H8v10h16V13h-1zm-5-4v2h-3V9z"
clipRule="evenodd"
/>
</svg>
);
export default Extension;

View File

@ -0,0 +1,292 @@
import React from "react";
import type { SVGProps } from "react";
const Firefox = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" {...props}>
<path
fill="#fff"
stroke="#E2E4EA"
d="M.5 8A7.5 7.5 0 0 1 8 .5h16A7.5 7.5 0 0 1 31.5 8v16a7.5 7.5 0 0 1-7.5 7.5H8A7.5 7.5 0 0 1 .5 24z"
/>
<g clipPath="url(#Name=firefox_svg__a)">
<path
fill="url(#Name=firefox_svg__b)"
d="M23.288 6.233c-.584.68-.857 2.21-.264 3.762.591 1.552 1.5 1.215 2.066 2.797.747 2.087.399 4.892.399 4.892s.898 2.6 1.523-.162c1.384-5.186-3.724-10.011-3.724-11.29"
/>
<path
fill="url(#Name=firefox_svg__c)"
d="M16.16 27.771c5.981 0 10.826-4.872 10.826-10.878S22.141 6.015 16.166 6.015c-5.976 0-10.82 4.871-10.82 10.878-.012 6.013 4.839 10.878 10.814 10.878"
/>
<path
fill="url(#Name=firefox_svg__d)"
d="M24.273 23.6c-.235.166-.48.313-.735.443.338-.495.65-1.008.934-1.536.231-.256.442-.504.614-.772.084-.132.178-.294.279-.484.607-1.095 1.278-2.867 1.297-4.687v-.138a6.26 6.26 0 0 0-.14-1.36l.014.105-.016-.081.024.145c.123 1.054.035 2.083-.407 2.84l-.022.033c.23-1.152.307-2.424.05-3.698 0 0-.102-.618-.862-2.498-.438-1.082-1.215-1.97-1.903-2.615-.602-.745-1.15-1.245-1.45-1.562-.63-.663-.894-1.16-1.002-1.484-.094-.048-1.296-1.215-1.392-1.26-.525.815-2.175 3.358-1.39 5.736.356 1.078 1.256 2.196 2.197 2.823.042.048.56.611.807 1.883.254 1.313.12 2.337-.404 3.853-.616 1.33-2.196 2.644-3.676 2.778-3.162.287-4.321-1.588-4.321-1.588 1.13.452 2.38.357 3.14-.112.766-.473 1.23-.825 1.605-.686.37.138.666-.262.401-.677a1.914 1.914 0 0 0-1.936-.843c-.767.125-1.469.731-2.474.144a1.761 1.761 0 0 1-.188-.124c-.067-.044.215.066.15.017a4.935 4.935 0 0 1-.632-.42c-.014-.014.153.053.137.039-.939-.773-.822-1.296-.792-1.624.023-.263.193-.599.48-.735.14.076.226.134.226.134l-.091-.185c.011-.004.021-.003.033-.008a6.1 6.1 0 0 1 .498.284c.172.122.227.23.227.23s.045-.024.012-.13a.68.68 0 0 0-.236-.321h.01c.103.06.2.126.29.201.048-.174.135-.358.116-.685-.011-.23-.007-.29-.047-.379-.036-.076.02-.106.083-.027a.831.831 0 0 0-.054-.18v-.006c.079-.273 1.665-.987 1.78-1.07.189-.135.348-.307.466-.508.088-.14.155-.338.17-.636.01-.216-.091-.36-1.695-.528-.438-.043-.695-.36-.842-.653a5.496 5.496 0 0 0-.08-.178 1.31 1.31 0 0 1-.063-.206c.263-.752.702-1.39 1.35-1.87.035-.033-.14.007-.106-.024.042-.037.31-.146.361-.17.062-.03-.265-.168-.554-.134-.294.032-.358.068-.514.135.065-.066.272-.15.224-.15-.318.048-.711.233-1.05.442a.265.265 0 0 1 .021-.106c-.157.066-.543.336-.655.564a1.15 1.15 0 0 0 .007-.131c-.12.1-.228.213-.322.337l-.006.005c-.91-.367-1.713-.39-2.392-.226-.148-.15-.22-.04-.559-.783-.022-.045.018.044 0 0-.055-.144.034.192 0 0-.568.449-1.315.956-1.673 1.315-.005.014.418-.12 0 0-.147.041-.137.128-.16.915-.005.06 0 .126-.005.18-.286.365-.481.674-.555.834-.371.64-.78 1.634-1.175 3.209.175-.427.385-.839.63-1.23-.33.836-.648 2.148-.71 4.169.08-.417.18-.83.305-1.235a11.53 11.53 0 0 0 .847 4.904c.228.556.604 1.4 1.244 2.327a11 11 0 0 0 7.997 3.436c3.286 0 6.234-1.436 8.26-3.717"
/>
<path
fill="url(#Name=firefox_svg__e)"
d="M21.844 25.139c3.973-.46 5.73-4.554 3.472-4.635-2.04-.064-5.354 4.852-3.472 4.634"
/>
<path
fill="url(#Name=firefox_svg__f)"
d="M25.602 19.669c2.733-1.59 2.021-5.026 2.021-5.026s-1.055 1.224-1.771 3.178c-.708 1.934-1.893 2.808-.25 1.848"
/>
<path
fill="url(#Name=firefox_svg__g)"
d="M16.926 27.172c3.81 1.216 7.085-1.787 5.067-2.789-1.836-.903-6.875 2.214-5.067 2.789"
/>
<path
fill="url(#Name=firefox_svg__h)"
d="M25.886 21.127c.093-.13.218-.55.33-.737.672-1.086.677-1.952.677-1.97.407-2.03.37-2.859.12-4.392-.202-1.234-1.082-3.002-1.843-3.853-.786-.877-.233-.592-.993-1.232-.666-.74-1.313-1.47-1.665-1.765-2.544-2.126-2.486-2.578-2.438-2.655l-.036.04c-.03-.12-.052-.22-.052-.22s-1.39 1.39-1.682 3.706c-.192 1.513.375 3.09 1.195 4.098.427.522.909.997 1.439 1.415v-.001c.62.889.96 1.986.96 3.168a5.356 5.356 0 0 1-6.555 5.22c-1.396-.266-2.202-.97-2.604-1.448a2.636 2.636 0 0 1-.328-.474c1.25.449 2.633.354 3.474-.11.848-.47 1.36-.818 1.776-.68.41.136.737-.261.444-.672-.287-.412-1.034-1-2.143-.837-.849.124-1.626.726-2.738.142a2.149 2.149 0 0 1-.208-.123c-.073-.042.238.066.165.017a5.505 5.505 0 0 1-.698-.415c-.017-.014.168.052.15.039-1.038-.768-.909-1.285-.876-1.61a.932.932 0 0 1 .533-.729c.153.076.25.133.25.133L12.437 15c.012-.005.024-.004.036-.008.126.054.404.196.55.282.191.12.252.228.252.228s.05-.024.014-.13c-.014-.042-.071-.18-.26-.318h.01c.113.058.22.125.32.2.054-.173.15-.355.13-.68-.013-.227-.008-.287-.053-.375-.04-.075.023-.104.093-.027a.73.73 0 0 0-.06-.179l.001-.005c.087-.272 1.843-.979 1.97-1.062a1.72 1.72 0 0 0 .516-.503c.098-.14.172-.334.19-.63.005-.135-.035-.24-.5-.341-.28-.061-.712-.12-1.377-.183-.486-.043-.77-.358-.932-.649-.03-.063-.06-.12-.09-.176a1.312 1.312 0 0 1-.069-.204 3.867 3.867 0 0 1 1.497-1.856c.04-.032-.157.008-.117-.024.045-.037.342-.144.399-.169.068-.029-.294-.166-.613-.133-.326.033-.395.068-.57.134.072-.064.303-.148.248-.147-.351.048-.787.23-1.16.438a.226.226 0 0 1 .022-.105c-.174.067-.6.334-.725.56a.848.848 0 0 0 .007-.13c-.13.098-.25.21-.356.333l-.007.006c-1.008-.364-1.895-.388-2.647-.224-.165-.148-.428-.372-.803-1.107-.025-.044-.039.091-.058.047-.146-.337-.233-.889-.219-1.269 0 0-.3.137-.549.71-.046.102-.075.16-.104.216-.014.017.03-.189.023-.177-.043.073-.155.175-.204.307-.034.098-.081.154-.111.275l-.007.012c-.002-.036.009-.15.001-.125a5.848 5.848 0 0 0-.3.728 6.752 6.752 0 0 0-.314 1.819c-.006.058-.001.125-.006.178-.317.361-.533.668-.614.827-.411.631-.863 1.618-1.302 3.18.195-.427.428-.836.697-1.22-.364.828-.716 2.13-.787 4.135.088-.415.202-.824.338-1.225a10.373 10.373 0 0 0 .938 4.863c.501 1.1 1.657 3.332 4.479 5.075 2.822 1.742.96.714 2.61 1.25.121.044.245.088.372.13l-.115-.05c1.099.329 2.238.497 3.385.497 4.273.003 5.533-1.712 5.533-1.712l-.012.009c.06-.057.118-.116.174-.177-.674.637-2.212.678-2.788.633.98-.288 1.626-.532 2.882-1.013.146-.054.297-.116.45-.186l.05-.022c.03-.015.061-.027.091-.043a8.524 8.524 0 0 0 1.715-1.073c1.26-1.008 1.535-1.99 1.679-2.636-.02.062-.082.206-.126.3-.325.695-1.045 1.122-1.827 1.487.372-.488.716-.996 1.033-1.522.255-.253.335-.65.525-.916"
/>
<path
fill="url(#Name=firefox_svg__i)"
d="M24.351 23.536a8.067 8.067 0 0 0 1.326-1.951c.9-1.892 2.291-5.039 1.196-8.324-.867-2.597-2.056-4.017-3.565-5.404C20.855 5.604 20.17 4.598 20.17 4c0 0-2.83 3.157-1.604 6.448 1.228 3.29 3.743 3.171 5.407 6.606 1.957 4.043-1.585 8.454-4.514 9.69.18-.04 6.51-1.474 6.844-5.095-.006.067-.15 1.067-1.952 1.887"
/>
<path
fill="url(#Name=firefox_svg__j)"
d="M16.148 11.789c.01-.214-.102-.357-1.87-.523-.728-.068-1.006-.74-1.09-1.023-.26.672-.366 1.377-.309 2.231.04.56.415 1.159.594 1.511 0 0 .04-.051.059-.07.338-.352 1.755-.889 1.888-.965.145-.093.704-.5.728-1.161"
/>
<path
fill="url(#Name=firefox_svg__k)"
d="M8.361 7.816c-.026-.044-.04.091-.058.047-.146-.337-.234-.882-.213-1.269 0 0-.301.137-.55.71a3.38 3.38 0 0 1-.104.216c-.014.017.03-.189.024-.177-.043.073-.156.176-.204.302-.041.103-.082.159-.113.287-.01.035.009-.154.001-.13-.578 1.117-.689 2.807-.628 2.736 1.231-1.314 2.642-1.626 2.642-1.626-.149-.112-.476-.431-.797-1.096"
/>
<path
fill="url(#Name=firefox_svg__l)"
d="M13.033 21.564c-1.701-.726-3.634-1.75-3.562-4.077.1-3.063 2.857-2.458 2.857-2.458-.103.025-.382.223-.48.435-.105.264-.295.86.28 1.486.906.98-1.858 2.326 2.407 4.867.108.058-.998-.036-1.502-.253"
/>
<path
fill="url(#Name=firefox_svg__m)"
d="M12.429 20.037c1.205.419 2.61.346 3.452-.119.563-.313 1.285-.816 1.73-.692-.385-.152-.677-.223-1.027-.24a1.9 1.9 0 0 1-.196-.008 3.3 3.3 0 0 0-.385.022c-.217.02-.457.156-.676.135-.01-.001.212-.093.194-.088-.116.024-.242.03-.375.046-.085.01-.158.02-.241.024-2.512.213-4.635-1.362-4.635-1.362-.181.608.808 1.812 2.159 2.282"
/>
<path
fill="url(#Name=firefox_svg__n)"
d="M24.347 23.552c2.54-2.494 3.826-5.526 3.282-8.928 0 0 .217 1.744-.607 3.528.396-1.741.442-3.905-.608-6.146-1.402-2.99-3.708-4.564-4.59-5.221-1.333-.995-1.886-2.007-1.896-2.217-.4.816-1.604 3.614-.13 6.024 1.382 2.257 3.558 2.928 5.08 4.998 2.808 3.816-.531 7.962-.531 7.962"
/>
<path
fill="url(#Name=firefox_svg__o)"
d="M23.983 17.062c-.888-1.834-1.996-2.634-3.044-3.502.122.17.152.232.22.342.923.983 2.283 3.382 1.294 6.393-1.86 5.667-9.295 2.999-10.075 2.25.316 3.279 5.805 4.849 9.38 2.722 2.034-1.926 3.68-5.199 2.225-8.205"
/>
</g>
<defs>
<radialGradient
id="Name=firefox_svg__b"
cx={0}
cy={0}
r={1}
gradientTransform="rotate(2.555 -210.924 602.667)scale(8.38769 12.3287)"
gradientUnits="userSpaceOnUse"
>
<stop offset={0.045} stopColor="#FFEA00" />
<stop offset={0.12} stopColor="#FFDE00" />
<stop offset={0.254} stopColor="#FFBF00" />
<stop offset={0.429} stopColor="#FF8E00" />
<stop offset={0.769} stopColor="#FF272D" />
<stop offset={0.872} stopColor="#E0255A" />
<stop offset={0.953} stopColor="#CC2477" />
<stop offset={1} stopColor="#C42482" />
</radialGradient>
<radialGradient
id="Name=firefox_svg__c"
cx={0}
cy={0}
r={1}
gradientTransform="translate(20.673 8.71)scale(23.6401)"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#00CCDA" />
<stop offset={0.22} stopColor="#0083FF" />
<stop offset={0.261} stopColor="#007AF9" />
<stop offset={0.33} stopColor="#0060E8" />
<stop offset={0.333} stopColor="#005FE7" />
<stop offset={0.438} stopColor="#2639AD" />
<stop offset={0.522} stopColor="#401E84" />
<stop offset={0.566} stopColor="#4A1475" />
</radialGradient>
<radialGradient
id="Name=firefox_svg__e"
cx={0}
cy={0}
r={1}
gradientTransform="rotate(5.67 -152.86 252.646)scale(7.97377)"
gradientUnits="userSpaceOnUse"
>
<stop offset={0.003} stopColor="#FFEA00" />
<stop offset={0.497} stopColor="#FF272D" />
<stop offset={1} stopColor="#C42482" />
</radialGradient>
<radialGradient
id="Name=firefox_svg__f"
cx={0}
cy={0}
r={1}
gradientTransform="rotate(5.67 -75.297 249.185)scale(13.3156)"
gradientUnits="userSpaceOnUse"
>
<stop offset={0.003} stopColor="#FFE900" />
<stop offset={0.157} stopColor="#FFAF0E" />
<stop offset={0.316} stopColor="#FF7A1B" />
<stop offset={0.472} stopColor="#FF4E26" />
<stop offset={0.621} stopColor="#FF2C2E" />
<stop offset={0.762} stopColor="#FF1434" />
<stop offset={0.892} stopColor="#FF0538" />
<stop offset={1} stopColor="#FF0039" />
</radialGradient>
<radialGradient
id="Name=firefox_svg__g"
cx={0}
cy={0}
r={1}
gradientTransform="rotate(5.67 -200.873 262.03)scale(12.2185)"
gradientUnits="userSpaceOnUse"
>
<stop offset={0.003} stopColor="#FF272D" />
<stop offset={0.497} stopColor="#C42482" />
<stop offset={0.986} stopColor="#620700" />
</radialGradient>
<radialGradient
id="Name=firefox_svg__h"
cx={0}
cy={0}
r={1}
gradientTransform="translate(21.943 13.61)scale(19.0773)"
gradientUnits="userSpaceOnUse"
>
<stop offset={0.156} stopColor="#FFEA00" />
<stop offset={0.231} stopColor="#FFDE00" />
<stop offset={0.365} stopColor="#FFBF00" />
<stop offset={0.541} stopColor="#FF8E00" />
<stop offset={0.763} stopColor="#FF272D" />
<stop offset={0.796} stopColor="#F92433" />
<stop offset={0.841} stopColor="#E91C45" />
<stop offset={0.893} stopColor="#CF0E62" />
<stop offset={0.935} stopColor="#B5007F" />
</radialGradient>
<radialGradient
id="Name=firefox_svg__i"
cx={0}
cy={0}
r={1}
gradientTransform="translate(20.508 4.031)scale(22.5269)"
gradientUnits="userSpaceOnUse"
>
<stop offset={0.279} stopColor="#FFEA00" />
<stop offset={0.402} stopColor="#FD0" />
<stop offset={0.63} stopColor="#FFBA00" />
<stop offset={0.856} stopColor="#FF9100" />
<stop offset={0.933} stopColor="#FF6711" />
<stop offset={0.994} stopColor="#FF4A1D" />
</radialGradient>
<linearGradient
id="Name=firefox_svg__d"
x1={17.686}
x2={13.017}
y1={21.785}
y2={6.511}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#000F43" stopOpacity={0.4} />
<stop offset={0.485} stopColor="#001962" stopOpacity={0.173} />
<stop offset={1} stopColor="#002079" stopOpacity={0} />
</linearGradient>
<linearGradient
id="Name=firefox_svg__j"
x1={4.947}
x2={13.615}
y1={13.391}
y2={12.075}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#C42482" stopOpacity={0.5} />
<stop offset={0.474} stopColor="#FF272D" stopOpacity={0.5} />
<stop offset={0.486} stopColor="#FF2C2C" stopOpacity={0.513} />
<stop offset={0.675} stopColor="#FF7A1A" stopOpacity={0.72} />
<stop offset={0.829} stopColor="#FFB20D" stopOpacity={0.871} />
<stop offset={0.942} stopColor="#FFD605" stopOpacity={0.964} />
<stop offset={1} stopColor="#FFE302" />
</linearGradient>
<linearGradient
id="Name=firefox_svg__k"
x1={7.993}
x2={7.009}
y1={10.288}
y2={6.762}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#891551" stopOpacity={0.6} />
<stop offset={1} stopColor="#C42482" stopOpacity={0} />
</linearGradient>
<linearGradient
id="Name=firefox_svg__l"
x1={9.878}
x2={11.902}
y1={15.441}
y2={18.264}
gradientUnits="userSpaceOnUse"
>
<stop offset={0.005} stopColor="#891551" stopOpacity={0.5} />
<stop offset={0.484} stopColor="#FF272D" stopOpacity={0.5} />
<stop offset={1} stopColor="#FF272D" stopOpacity={0} />
</linearGradient>
<linearGradient
id="Name=firefox_svg__m"
x1={13.743}
x2={13.927}
y1={20.39}
y2={18.576}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#C42482" />
<stop offset={0.083} stopColor="#C42482" stopOpacity={0.81} />
<stop offset={0.206} stopColor="#C42482" stopOpacity={0.565} />
<stop offset={0.328} stopColor="#C42482" stopOpacity={0.362} />
<stop offset={0.447} stopColor="#C42482" stopOpacity={0.204} />
<stop offset={0.562} stopColor="#C42482" stopOpacity={0.091} />
<stop offset={0.673} stopColor="#C42482" stopOpacity={0.023} />
<stop offset={0.773} stopColor="#C42482" stopOpacity={0} />
</linearGradient>
<linearGradient
id="Name=firefox_svg__n"
x1={19.635}
x2={27.09}
y1={4.88}
y2={21.551}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FFF14F" />
<stop offset={0.268} stopColor="#FFEE4C" />
<stop offset={0.452} stopColor="#FFE643" />
<stop offset={0.612} stopColor="#FFD834" />
<stop offset={0.757} stopColor="#FFC41E" />
<stop offset={0.892} stopColor="#FFAB02" />
<stop offset={0.902} stopColor="#FFA900" />
<stop offset={0.949} stopColor="#FFA000" />
<stop offset={1} stopColor="#FF9100" />
</linearGradient>
<linearGradient
id="Name=firefox_svg__o"
x1={21.107}
x2={17.575}
y1={14.468}
y2={23.95}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FF8E00" />
<stop offset={0.04} stopColor="#FF8E00" stopOpacity={0.858} />
<stop offset={0.084} stopColor="#FF8E00" stopOpacity={0.729} />
<stop offset={0.13} stopColor="#FF8E00" stopOpacity={0.628} />
<stop offset={0.178} stopColor="#FF8E00" stopOpacity={0.557} />
<stop offset={0.227} stopColor="#FF8E00" stopOpacity={0.514} />
<stop offset={0.282} stopColor="#FF8E00" stopOpacity={0.5} />
<stop offset={0.389} stopColor="#FF8E00" stopOpacity={0.478} />
<stop offset={0.524} stopColor="#FF8E00" stopOpacity={0.416} />
<stop offset={0.676} stopColor="#FF8E00" stopOpacity={0.314} />
<stop offset={0.838} stopColor="#FF8E00" stopOpacity={0.172} />
<stop offset={1} stopColor="#FF8E00" stopOpacity={0} />
</linearGradient>
<clipPath id="Name=firefox_svg__a">
<path fill="#fff" d="M4.5 4h23.253v24H4.5z" />
</clipPath>
</defs>
</svg>
);
export default Firefox;

View File

@ -0,0 +1,20 @@
import React from "react";
import type { SVGProps } from "react";
const MacApp = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" {...props}>
<path
fill="#F9FAFC"
stroke="#E2E4EA"
d="M.5 8A7.5 7.5 0 0 1 8 .5h16A7.5 7.5 0 0 1 31.5 8v16a7.5 7.5 0 0 1-7.5 7.5H8A7.5 7.5 0 0 1 .5 24z"
/>
<path
fill="#515774"
fillRule="evenodd"
d="M20.44 5.5c.14 1.179-.424 2.337-1.176 3.179-.776.863-2.046 1.494-3.268 1.41-.165-1.137.447-2.337 1.176-3.073.799-.842 2.139-1.474 3.267-1.516m2.092 12.399v.02c.4.78 1.528 1.959 2.468 2.253-.012.042-.03.09-.047.137a1.724 1.724 0 0 0-.047.137c-.259.716-.964 1.915-1.434 2.547-.94 1.22-1.88 2.42-3.409 2.484-.717-.01-1.2-.197-1.691-.388-.54-.21-1.09-.423-1.976-.412-.908-.011-1.478.214-2.029.431-.476.188-.938.37-1.592.39-1.457.063-2.562-1.305-3.503-2.505-.446-.631-1.175-1.873-1.434-2.526-.258-.61-.634-1.831-.705-2.463v-.042c-.094-.632-.188-1.937-.094-2.442v-.042c.07-.779.494-2.02.87-2.547a5.357 5.357 0 0 1 4.396-2.484s.259-.021.282-.021h.024c.783-.036 1.543.244 2.214.49.517.19.98.361 1.36.352.366.008.863-.16 1.433-.353.818-.278 1.785-.606 2.727-.531 1.599.02 3.15.694 4.114 1.957a8.621 8.621 0 0 0-.776.569c0 .02-.023.02-.023.02-.611.4-1.363 1.474-1.505 2.527v.021c-.14.8.024 1.79.377 2.42"
clipRule="evenodd"
/>
</svg>
);
export default MacApp;

View File

@ -0,0 +1,20 @@
import React from "react";
import type { SVGProps } from "react";
const Package = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" {...props}>
<path
fill="#F9FAFC"
stroke="#E2E4EA"
d="M.5 8A7.5 7.5 0 0 1 8 .5h16A7.5 7.5 0 0 1 31.5 8v16a7.5 7.5 0 0 1-7.5 7.5H8A7.5 7.5 0 0 1 .5 24z"
/>
<path
fill="#E18F13"
fillRule="evenodd"
d="M15.88 5c-.521 0-1.033.137-1.485.397L7.491 9.343l-.004.002A2.973 2.973 0 0 0 6 11.917v7.896a2.974 2.974 0 0 0 1.487 2.572l.004.002 6.902 3.945h.002a2.973 2.973 0 0 0 2.97.001l.002-.001 6.902-3.945.004-.002a2.973 2.973 0 0 0 1.487-2.572v-7.897a2.974 2.974 0 0 0-1.487-2.572l-.004-.002-6.902-3.945h-.003A2.974 2.974 0 0 0 15.88 5m1 19.305 6.393-3.653h.002a.973.973 0 0 0 .485-.842v-7.32l-6.88 3.953zm-2-7.862L8 12.49v7.322a.974.974 0 0 0 .485.84l.002.001 6.393 3.653zm.513-9.313a.973.973 0 0 1 .973 0l.004.003 6.367 3.638-2.675 1.536-6.86-3.925 2.187-1.25zm.487 7.581-6.857-3.94 2.668-1.525 6.864 3.928z"
clipRule="evenodd"
/>
</svg>
);
export default Package;

View File

@ -0,0 +1,584 @@
import React from "react";
import type { SVGProps } from "react";
const Safari = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" {...props}>
<path
fill="#fff"
stroke="#E2E4EA"
d="M.5 8A7.5 7.5 0 0 1 8 .5h16A7.5 7.5 0 0 1 31.5 8v16a7.5 7.5 0 0 1-7.5 7.5H8A7.5 7.5 0 0 1 .5 24z"
/>
<path
fill="url(#Name=safari_svg__a)"
d="M16 28c6.627 0 12-5.373 12-12S22.627 4 16 4 4 9.373 4 16s5.373 12 12 12"
/>
<path
fill="url(#Name=safari_svg__b)"
d="M16 27.1c6.13 0 11.1-4.97 11.1-11.1S22.13 4.9 16 4.9 4.9 9.87 4.9 16 9.87 27.1 16 27.1"
/>
<path
fill="#F3F3F3"
d="M16.018 7.6c-.075 0-.14-.056-.14-.131v-1.96c0-.075.065-.13.14-.13.075 0 .141.055.141.13v1.96c-.01.075-.066.131-.14.131"
/>
<path
fill="#fff"
d="M16.019 7.61a.146.146 0 0 1-.15-.141v-1.96c0-.075.065-.14.15-.14.084 0 .15.065.15.14v1.96c0 .075-.075.14-.15.14m0-2.223c-.066 0-.132.057-.132.122v1.96c0 .065.057.121.132.121.065 0 .13-.056.13-.121v-1.96c-.009-.065-.065-.122-.13-.122"
/>
<path
fill="#F3F3F3"
d="M16.957 6.55c-.075-.01-.132-.066-.132-.14l.066-.872a.137.137 0 0 1 .15-.122c.075.01.131.065.131.14l-.065.872a.137.137 0 0 1-.15.122"
/>
<path
fill="#fff"
d="M16.956 6.56a.152.152 0 0 1-.14-.15l.065-.873c.01-.074.075-.14.16-.13.084.009.14.074.14.15l-.066.871c-.01.085-.075.14-.16.131m.084-1.135c-.065 0-.131.047-.131.113l-.066.872c0 .065.047.121.122.13.066 0 .131-.046.131-.112l.066-.872c0-.065-.056-.122-.122-.131"
/>
<path
fill="#F3F3F3"
d="M17.659 7.769a.144.144 0 0 1-.113-.16l.385-1.921c.019-.075.084-.122.16-.104a.144.144 0 0 1 .112.16l-.385 1.922c-.01.065-.084.112-.16.103"
/>
<path
fill="#fff"
d="M17.66 7.778a.153.153 0 0 1-.122-.169l.384-1.922a.15.15 0 0 1 .178-.112.153.153 0 0 1 .122.169l-.384 1.922c-.019.074-.094.121-.178.112m.44-2.184a.135.135 0 0 0-.15.093l-.384 1.922a.133.133 0 0 0 .103.15.135.135 0 0 0 .15-.094l.384-1.921c.01-.075-.037-.132-.103-.15"
/>
<path
fill="#F3F3F3"
d="M18.784 6.925a.14.14 0 0 1-.103-.169l.234-.844c.019-.075.094-.112.169-.093a.14.14 0 0 1 .103.168l-.234.844c-.02.075-.094.113-.17.094"
/>
<path
fill="#fff"
d="M18.784 6.934c-.075-.019-.131-.103-.103-.178l.234-.844a.15.15 0 0 1 .178-.103c.075.019.132.103.104.178l-.235.844c-.019.075-.094.122-.178.103m.31-1.097c-.066-.018-.141.02-.16.085l-.234.843c-.019.066.018.132.093.15a.13.13 0 0 0 .16-.084l.234-.844a.134.134 0 0 0-.094-.15"
/>
<path
fill="#F3F3F3"
d="M19.244 8.247a.134.134 0 0 1-.075-.178l.75-1.81c.028-.065.112-.103.178-.065a.134.134 0 0 1 .075.178l-.75 1.81c-.028.065-.113.102-.178.065"
/>
<path
fill="#fff"
d="M19.244 8.256a.146.146 0 0 1-.085-.187l.75-1.81c.028-.074.113-.103.197-.074a.145.145 0 0 1 .085.187l-.75 1.81c-.038.074-.122.112-.197.074m.853-2.053a.127.127 0 0 0-.169.066l-.75 1.81c-.028.065.01.13.075.159.066.028.14 0 .169-.066l.75-1.81a.133.133 0 0 0-.075-.159"
/>
<path
fill="#F3F3F3"
d="M20.51 7.647c-.066-.038-.094-.113-.066-.178l.394-.778a.137.137 0 0 1 .187-.057c.066.038.094.113.066.178l-.394.778a.137.137 0 0 1-.187.057"
/>
<path
fill="#fff"
d="M20.51 7.656a.143.143 0 0 1-.066-.197l.393-.778a.143.143 0 0 1 .197-.065.143.143 0 0 1 .066.196l-.394.779a.143.143 0 0 1-.197.065m.515-1.022c-.066-.028-.14-.01-.169.057l-.394.778c-.028.056 0 .13.057.168.065.029.14.01.169-.056l.393-.778c.028-.056 0-.131-.056-.169"
/>
<path
fill="#F3F3F3"
d="M20.697 9.035c-.066-.047-.085-.132-.038-.188l1.088-1.622c.037-.065.122-.075.187-.037.066.047.084.131.038.187l-1.088 1.622a.127.127 0 0 1-.187.038"
/>
<path
fill="#fff"
d="M20.697 9.044a.162.162 0 0 1-.047-.207l1.087-1.622a.148.148 0 0 1 .206-.037.16.16 0 0 1 .047.206l-1.087 1.622a.15.15 0 0 1-.206.038m1.237-1.847c-.056-.038-.14-.028-.178.028l-1.088 1.622a.122.122 0 0 0 .038.168c.056.038.14.029.178-.028l1.087-1.612a.133.133 0 0 0-.037-.178"
/>
<path
fill="#F3F3F3"
d="M22.066 8.687c-.057-.047-.075-.131-.028-.187l.543-.685a.134.134 0 0 1 .188-.018c.056.047.075.131.028.187l-.544.685a.134.134 0 0 1-.187.018"
/>
<path
fill="#fff"
d="M22.057 8.697c-.066-.047-.075-.14-.028-.207l.543-.684c.047-.066.14-.075.206-.019.066.047.075.141.028.207l-.543.684c-.047.066-.14.075-.206.019m.703-.891a.123.123 0 0 0-.178.019l-.544.684a.133.133 0 0 0 .028.178c.056.047.131.038.178-.018l.544-.685c.037-.056.028-.14-.028-.178"
/>
<path
fill="#F3F3F3"
d="M21.972 10.094c-.056-.056-.056-.14 0-.188l1.387-1.378a.133.133 0 0 1 .188 0c.047.047.056.14 0 .188l-1.378 1.378a.136.136 0 0 1-.197 0"
/>
<path
fill="#fff"
d="M21.963 10.103a.147.147 0 0 1 0-.206l1.387-1.378a.147.147 0 0 1 .206 0 .147.147 0 0 1 0 .206l-1.387 1.378a.147.147 0 0 1-.206 0m1.584-1.575a.13.13 0 0 0-.178 0l-1.387 1.378a.13.13 0 0 0 0 .178.13.13 0 0 0 .178 0l1.387-1.378c.047-.047.047-.122 0-.178"
/>
<path
fill="#F3F3F3"
d="M23.378 10.019c-.047-.057-.047-.141.01-.188l.665-.562a.134.134 0 0 1 .188.018c.047.057.047.141-.01.188l-.665.562a.134.134 0 0 1-.188-.018"
/>
<path
fill="#fff"
d="M23.369 10.028c-.056-.066-.047-.16.009-.206l.666-.563c.056-.056.15-.047.206.02.056.065.047.158-.01.205l-.665.563c-.056.056-.15.047-.206-.019m.862-.74c-.047-.057-.122-.057-.178-.02l-.666.563a.13.13 0 0 0-.009.178c.047.056.122.056.178.02l.666-.563c.056-.047.056-.122.01-.179"
/>
<path
fill="#F3F3F3"
d="M23.003 11.369a.127.127 0 0 1 .037-.188l1.632-1.087c.065-.038.15-.02.187.037a.127.127 0 0 1-.038.188l-1.63 1.087c-.057.038-.141.019-.188-.037"
/>
<path
fill="#fff"
d="M22.994 11.369a.15.15 0 0 1 .037-.207l1.631-1.087a.144.144 0 0 1 .207.047.15.15 0 0 1-.038.206L23.2 11.416a.144.144 0 0 1-.206-.047m1.856-1.228a.122.122 0 0 0-.169-.038l-1.631 1.088c-.056.037-.075.112-.028.178a.122.122 0 0 0 .169.037l1.63-1.087a.133.133 0 0 0 .029-.178"
/>
<path
fill="#F3F3F3"
d="M24.4 11.566a.137.137 0 0 1 .047-.188l.759-.431a.138.138 0 0 1 .187.056.136.136 0 0 1-.047.187l-.759.432a.139.139 0 0 1-.187-.056"
/>
<path
fill="#fff"
d="M24.39 11.575c-.037-.075-.018-.16.047-.197l.76-.431c.065-.038.16-.01.197.056.037.075.018.16-.047.197l-.76.431c-.065.038-.16.01-.197-.056m.994-.563c-.037-.065-.112-.084-.168-.047l-.76.432a.134.134 0 0 0-.047.168c.038.066.113.085.169.047l.76-.431c.056-.028.074-.103.046-.169"
/>
<path
fill="#F3F3F3"
d="M23.772 12.813c-.028-.075 0-.15.075-.179l1.81-.75c.065-.028.15.01.177.075.029.075 0 .15-.075.178l-1.809.75a.134.134 0 0 1-.178-.075"
/>
<path
fill="#fff"
d="M23.763 12.822a.156.156 0 0 1 .075-.197l1.809-.75a.146.146 0 0 1 .187.085.153.153 0 0 1-.075.196l-1.809.75a.146.146 0 0 1-.187-.084m2.053-.853a.133.133 0 0 0-.16-.075l-1.809.75a.127.127 0 0 0-.066.169.133.133 0 0 0 .16.075l1.81-.75a.128.128 0 0 0 .065-.169"
/>
<path
fill="#F3F3F3"
d="M25.094 13.282c-.028-.076.01-.15.084-.179l.825-.272c.066-.028.15.02.169.085.028.075-.01.15-.085.178l-.824.272c-.066.028-.141-.01-.17-.085"
/>
<path
fill="#fff"
d="M25.085 13.29a.143.143 0 0 1 .093-.187l.825-.272c.075-.028.16.019.188.094a.143.143 0 0 1-.094.188l-.825.271a.16.16 0 0 1-.187-.093m1.087-.356c-.019-.065-.094-.103-.16-.084l-.824.272c-.066.019-.094.094-.075.16.018.065.093.102.16.084l.824-.272a.13.13 0 0 0 .075-.16"
/>
<path
fill="#F3F3F3"
d="M24.25 14.397a.127.127 0 0 1 .103-.16l1.922-.375c.075-.018.14.038.16.113.018.075-.029.15-.104.16l-1.922.374c-.075.01-.15-.037-.159-.112"
/>
<path
fill="#fff"
d="M24.231 14.397a.15.15 0 0 1 .112-.178l1.922-.375c.075-.02.15.037.17.122a.15.15 0 0 1-.113.178l-1.922.375c-.075.018-.15-.038-.169-.122m2.184-.422a.126.126 0 0 0-.14-.103l-1.922.375a.122.122 0 0 0-.094.15c.01.065.075.112.14.103l1.923-.375c.065-.019.112-.085.093-.15"
/>
<path
fill="#F3F3F3"
d="M25.45 15.119a.13.13 0 0 1 .112-.15l.863-.103c.075-.01.14.046.15.121a.13.13 0 0 1-.113.15l-.862.104c-.066.009-.14-.047-.15-.122"
/>
<path
fill="#fff"
d="M25.44 15.119c-.009-.084.047-.16.122-.169l.863-.103c.075-.01.15.047.16.131.008.085-.048.16-.123.17l-.862.102a.145.145 0 0 1-.16-.131m1.135-.131c-.01-.066-.075-.122-.14-.113l-.863.103a.126.126 0 0 0-.104.14c.01.067.076.123.141.113l.863-.103a.126.126 0 0 0 .103-.14"
/>
<path
fill="#F3F3F3"
d="M24.4 16.028c0-.075.057-.14.132-.14h1.96c.074 0 .13.065.13.14 0 .075-.056.14-.13.14h-1.96c-.075 0-.131-.065-.131-.14"
/>
<path
fill="#fff"
d="M24.39 16.028c0-.084.066-.15.141-.15h1.96c.075 0 .14.066.14.15 0 .085-.065.15-.14.15h-1.96c-.075 0-.14-.066-.14-.15m2.223.01c0-.066-.057-.132-.122-.132h-1.96c-.065 0-.122.056-.122.132 0 .065.057.13.122.13h1.96c.065-.009.122-.065.122-.13"
/>
<path
fill="#F3F3F3"
d="M25.44 16.975c.01-.075.066-.131.141-.131l.872.065c.075.01.131.075.122.15-.01.075-.066.132-.141.132l-.872-.066a.153.153 0 0 1-.122-.15"
/>
<path
fill="#fff"
d="M25.431 16.975a.152.152 0 0 1 .15-.14l.872.065c.075.01.14.075.131.16a.152.152 0 0 1-.15.14l-.872-.066c-.075-.009-.13-.084-.13-.159m1.144.084a.119.119 0 0 0-.113-.13l-.872-.066a.13.13 0 0 0-.13.122.119.119 0 0 0 .112.13l.872.066c.065 0 .122-.056.131-.122"
/>
<path
fill="#F3F3F3"
d="M24.231 17.66a.144.144 0 0 1 .16-.113l1.921.384c.075.019.122.084.104.16a.144.144 0 0 1-.16.112l-1.922-.384c-.065-.01-.112-.085-.103-.16"
/>
<path
fill="#fff"
d="M24.222 17.66a.153.153 0 0 1 .169-.122l1.922.384a.15.15 0 0 1 .112.178.153.153 0 0 1-.169.122l-1.922-.384c-.075-.02-.122-.094-.112-.178m2.184.44a.134.134 0 0 0-.093-.15l-1.922-.384a.133.133 0 0 0-.15.103.135.135 0 0 0 .093.15l1.922.384c.075.01.132-.037.15-.103"
/>
<path
fill="#F3F3F3"
d="M25.075 18.784a.14.14 0 0 1 .169-.103l.844.235c.075.018.112.093.093.168a.14.14 0 0 1-.168.104l-.844-.235c-.075-.019-.113-.094-.094-.169"
/>
<path
fill="#fff"
d="M25.066 18.784c.019-.075.103-.13.178-.103l.844.235c.075.018.122.103.103.178-.019.075-.103.13-.178.103l-.844-.235c-.075-.018-.122-.093-.103-.178m1.097.31a.131.131 0 0 0-.085-.16l-.843-.234c-.066-.019-.131.019-.15.094a.131.131 0 0 0 .084.16l.844.233a.135.135 0 0 0 .15-.093"
/>
<path
fill="#F3F3F3"
d="M23.754 19.234a.134.134 0 0 1 .177-.075l1.81.75c.066.028.103.113.075.178a.134.134 0 0 1-.178.075l-1.81-.75a.134.134 0 0 1-.075-.178"
/>
<path
fill="#fff"
d="M23.743 19.225a.146.146 0 0 1 .188-.084l1.81.75c.074.028.102.112.074.197a.145.145 0 0 1-.187.084l-1.81-.75a.156.156 0 0 1-.075-.197m2.054.853a.127.127 0 0 0-.066-.168l-1.81-.75c-.065-.029-.13.009-.159.075-.028.065 0 .14.066.168l1.81.75c.065.02.14-.009.159-.075"
/>
<path
fill="#F3F3F3"
d="M24.362 20.5c.038-.066.113-.094.178-.066l.778.394c.066.028.094.113.057.188-.038.065-.113.093-.178.065l-.778-.394c-.066-.037-.094-.122-.057-.187"
/>
<path
fill="#fff"
d="M24.353 20.49a.143.143 0 0 1 .197-.065l.778.394a.143.143 0 0 1 .065.197.143.143 0 0 1-.197.065l-.778-.393c-.075-.029-.103-.122-.065-.197m1.012.516c.028-.066.01-.14-.056-.169l-.778-.393c-.056-.028-.131 0-.169.056-.028.066-.01.14.056.169l.779.393c.065.028.14.01.168-.056"
/>
<path
fill="#F3F3F3"
d="M22.984 20.678c.047-.065.122-.084.188-.037l1.631 1.087c.066.038.075.122.038.188-.047.065-.122.084-.188.037l-1.631-1.087c-.066-.047-.084-.132-.038-.188"
/>
<path
fill="#fff"
d="M22.975 20.669a.162.162 0 0 1 .206-.047l1.631 1.087a.148.148 0 0 1 .038.207.162.162 0 0 1-.207.047l-1.631-1.088c-.075-.047-.084-.14-.038-.206m1.846 1.237c.038-.056.029-.14-.028-.178l-1.631-1.087a.122.122 0 0 0-.169.037c-.037.056-.028.14.028.178l1.632 1.088a.122.122 0 0 0 .169-.038"
/>
<path
fill="#F3F3F3"
d="M23.332 22.038c.046-.057.13-.075.187-.028l.684.534a.134.134 0 0 1 .02.187c-.048.056-.132.075-.188.028l-.685-.534a.134.134 0 0 1-.018-.187"
/>
<path
fill="#fff"
d="M23.322 22.028c.047-.066.14-.075.206-.028l.684.534c.066.047.075.141.028.207-.046.065-.14.075-.206.028l-.684-.535c-.066-.047-.075-.14-.028-.206m.9.703c.046-.056.037-.131-.02-.178l-.684-.534a.133.133 0 0 0-.178.028c-.046.056-.037.131.02.178l.684.534a.133.133 0 0 0 .178-.028"
/>
<path
fill="#F3F3F3"
d="M21.935 21.944c.056-.056.14-.056.187 0l1.388 1.378a.133.133 0 0 1 0 .188c-.056.056-.14.056-.188 0l-1.387-1.379a.143.143 0 0 1 0-.187"
/>
<path
fill="#fff"
d="M21.935 21.934a.147.147 0 0 1 .206 0l1.387 1.378a.147.147 0 0 1 0 .207.147.147 0 0 1-.206 0l-1.387-1.378a.133.133 0 0 1 0-.207M23.51 23.5a.13.13 0 0 0 0-.178l-1.388-1.378a.13.13 0 0 0-.178 0 .13.13 0 0 0 0 .178l1.387 1.378c.047.056.122.056.179 0"
/>
<path
fill="#F3F3F3"
d="M22.019 23.34c.056-.046.14-.046.187.01l.572.656a.134.134 0 0 1-.019.188c-.056.047-.14.047-.187-.01L22 23.528c-.047-.047-.038-.131.019-.187"
/>
<path
fill="#fff"
d="M22.01 23.34a.147.147 0 0 1 .205.01l.572.656c.056.056.047.15-.018.206a.147.147 0 0 1-.207-.009l-.572-.656c-.046-.066-.046-.16.02-.207m.75.854c.055-.047.055-.122.018-.178l-.572-.657a.13.13 0 0 0-.178-.009c-.056.047-.056.122-.019.178l.572.656c.047.056.122.056.178.01"
/>
<path
fill="#F3F3F3"
d="M20.668 22.985a.127.127 0 0 1 .188.037l1.087 1.622c.038.066.02.15-.037.187a.127.127 0 0 1-.188-.037l-1.087-1.622c-.038-.066-.019-.15.037-.187"
/>
<path
fill="#fff"
d="M20.669 22.975a.15.15 0 0 1 .206.037l1.087 1.622a.144.144 0 0 1-.046.206.15.15 0 0 1-.207-.037l-1.087-1.622a.143.143 0 0 1 .047-.206m1.237 1.847a.122.122 0 0 0 .038-.17l-1.088-1.621c-.037-.056-.112-.066-.178-.028a.122.122 0 0 0-.037.169l1.087 1.621c.037.047.122.066.178.029"
/>
<path
fill="#F3F3F3"
d="M20.472 24.372a.137.137 0 0 1 .187.047l.432.76c.037.065.009.15-.057.187a.137.137 0 0 1-.187-.047l-.431-.76c-.029-.065-.01-.15.056-.187"
/>
<path
fill="#fff"
d="M20.472 24.362c.075-.037.16-.018.197.047l.431.76c.038.065.019.16-.056.197-.075.037-.16.018-.197-.047l-.431-.76c-.038-.065-.019-.15.056-.197m.563.994c.056-.037.084-.112.046-.169l-.43-.759a.135.135 0 0 0-.17-.047c-.056.038-.084.113-.047.169l.432.76c.028.056.112.084.169.046"
/>
<path
fill="#F3F3F3"
d="M19.225 23.753c.075-.028.15 0 .178.066l.76 1.81c.028.065-.01.15-.076.177-.065.028-.15 0-.177-.066l-.76-1.809c-.028-.066.01-.15.075-.178"
/>
<path
fill="#fff"
d="M19.225 23.744c.075-.028.16 0 .197.075l.76 1.81a.146.146 0 0 1-.085.187.155.155 0 0 1-.197-.075l-.76-1.81c-.027-.065.01-.15.085-.187m.853 2.053c.066-.028.094-.103.066-.16l-.76-1.809a.127.127 0 0 0-.168-.065c-.066.028-.094.103-.066.159l.76 1.81c.037.065.102.093.168.065"
/>
<path
fill="#F3F3F3"
d="M18.766 25.084c.075-.028.15.01.178.085l.272.825c.028.065-.02.15-.085.168-.075.029-.15-.009-.178-.084l-.272-.825c-.028-.066.01-.14.084-.169"
/>
<path
fill="#fff"
d="M18.756 25.075a.145.145 0 0 1 .188.084l.272.825c.028.075-.02.16-.094.188a.145.145 0 0 1-.188-.085l-.271-.824a.16.16 0 0 1 .093-.188m.366 1.078c.066-.019.103-.094.084-.16l-.272-.824c-.018-.066-.093-.094-.159-.075-.066.019-.103.094-.084.16l.271.824a.119.119 0 0 0 .16.075"
/>
<path
fill="#F3F3F3"
d="M17.659 24.231a.127.127 0 0 1 .16.103l.393 1.922c.019.075-.037.14-.112.16-.075.018-.15-.029-.16-.104l-.394-1.922a.15.15 0 0 1 .113-.159"
/>
<path
fill="#fff"
d="M17.66 24.222a.15.15 0 0 1 .177.112l.394 1.922c.019.075-.037.16-.112.17a.15.15 0 0 1-.178-.113l-.394-1.922c-.019-.075.028-.15.112-.17m.44 2.184c.066-.009.112-.075.103-.15l-.394-1.922c-.009-.065-.084-.112-.15-.093-.065.009-.112.075-.103.15l.394 1.922a.134.134 0 0 0 .15.093"
/>
<path
fill="#F3F3F3"
d="M16.947 25.45c.075-.01.14.037.159.112l.112.863a.136.136 0 0 1-.122.15.144.144 0 0 1-.159-.113l-.112-.862a.136.136 0 0 1 .122-.15"
/>
<path
fill="#fff"
d="M16.947 25.44c.084-.009.16.047.169.122l.112.863a.146.146 0 0 1-.131.16c-.085.009-.16-.048-.17-.123l-.111-.862a.146.146 0 0 1 .13-.16m.14 1.125c.066-.009.122-.075.113-.14l-.113-.863a.126.126 0 0 0-.14-.103c-.066.01-.122.075-.113.14l.113.863c.01.066.075.113.14.104"
/>
<path
fill="#F3F3F3"
d="M15.982 24.4c.075 0 .14.056.14.131v1.96c0 .074-.065.13-.14.13-.075 0-.141-.056-.141-.13v-1.96c.01-.075.066-.131.14-.131"
/>
<path
fill="#fff"
d="M15.98 24.39c.085 0 .15.066.15.141v1.96c0 .075-.065.14-.15.14-.084 0-.15-.065-.15-.14v-1.96c0-.075.076-.14.15-.14m0 2.223c.067 0 .132-.057.132-.122v-1.96c0-.065-.056-.122-.131-.122-.066 0-.131.057-.131.122v1.96c.009.065.065.122.13.122"
/>
<path
fill="#F3F3F3"
d="M15.044 25.45c.075.01.131.066.131.14l-.066.873a.137.137 0 0 1-.15.122c-.075-.01-.13-.066-.13-.141l.065-.872a.137.137 0 0 1 .15-.122"
/>
<path
fill="#fff"
d="M15.044 25.44c.084.01.14.075.14.15l-.065.872c-.01.075-.075.141-.16.132a.152.152 0 0 1-.14-.15l.065-.872c.01-.085.075-.14.16-.131m-.085 1.135c.066 0 .132-.047.132-.113l.066-.872c0-.065-.047-.121-.123-.13-.065 0-.13.046-.13.112l-.066.872c0 .065.056.122.122.13"
/>
<path
fill="#F3F3F3"
d="M14.34 24.231a.144.144 0 0 1 .113.16l-.385 1.922c-.018.075-.084.122-.16.103a.144.144 0 0 1-.112-.16l.385-1.922c.01-.065.084-.112.16-.103"
/>
<path
fill="#fff"
d="M14.34 24.222a.153.153 0 0 1 .122.169l-.384 1.922a.15.15 0 0 1-.178.112.153.153 0 0 1-.122-.169l.384-1.922c.02-.075.094-.122.178-.112m-.44 2.184a.135.135 0 0 0 .15-.093l.384-1.922a.133.133 0 0 0-.103-.15.135.135 0 0 0-.15.093l-.384 1.922c-.01.075.037.132.103.15"
/>
<path
fill="#F3F3F3"
d="M13.216 25.075a.14.14 0 0 1 .103.169l-.234.843c-.019.075-.094.113-.169.094a.139.139 0 0 1-.105-.114.141.141 0 0 1 .002-.055l.234-.843c.02-.075.094-.113.17-.094"
/>
<path
fill="#fff"
d="M13.216 25.066c.075.018.13.103.103.178l-.235.843c-.018.076-.103.122-.178.104-.075-.019-.131-.104-.103-.178l.234-.844a.146.146 0 0 1 .179-.103m-.31 1.097c.066.018.14-.02.16-.085l.234-.844c.019-.065-.019-.13-.094-.15a.134.134 0 0 0-.16.085l-.233.843a.134.134 0 0 0 .093.15"
/>
<path
fill="#F3F3F3"
d="M12.757 23.753a.134.134 0 0 1 .075.178l-.75 1.81c-.029.065-.113.103-.179.065-.065-.037-.103-.112-.075-.178l.75-1.81c.028-.065.113-.103.179-.065"
/>
<path
fill="#fff"
d="M12.756 23.744a.146.146 0 0 1 .084.187l-.75 1.81c-.028.074-.112.103-.197.074a.146.146 0 0 1-.084-.187l.75-1.81c.038-.074.122-.112.197-.074m-.853 2.053c.065.028.14 0 .168-.066l.75-1.81c.029-.065-.009-.13-.074-.159a.127.127 0 0 0-.17.066l-.75 1.81a.134.134 0 0 0 .076.159"
/>
<path
fill="#F3F3F3"
d="M11.491 24.354a.14.14 0 0 1 .066.178l-.394.777a.137.137 0 0 1-.188.057c-.065-.038-.093-.113-.065-.178l.394-.778a.137.137 0 0 1 .187-.056"
/>
<path
fill="#fff"
d="M11.491 24.344a.143.143 0 0 1 .066.197l-.394.778a.143.143 0 0 1-.197.066.143.143 0 0 1-.066-.197l.394-.778a.143.143 0 0 1 .197-.066m-.515 1.022c.065.028.14.01.168-.056l.394-.778c.028-.057 0-.132-.056-.17-.066-.027-.14-.009-.169.057l-.394.778c-.028.056 0 .131.056.169"
/>
<path
fill="#F3F3F3"
d="M11.303 22.966c.065.046.084.131.037.187l-1.087 1.622c-.038.066-.122.075-.188.038-.065-.047-.084-.131-.037-.188l1.087-1.622a.127.127 0 0 1 .188-.037"
/>
<path
fill="#fff"
d="M11.303 22.957c.065.046.084.14.047.206l-1.088 1.622a.148.148 0 0 1-.206.037.162.162 0 0 1-.047-.206l1.088-1.622a.15.15 0 0 1 .206-.038m-1.238 1.846c.056.038.141.028.178-.028l1.088-1.622a.122.122 0 0 0-.038-.168c-.056-.038-.14-.029-.178.028l-1.087 1.612a.133.133 0 0 0 .037.178"
/>
<path
fill="#F3F3F3"
d="M9.934 23.312c.056.047.075.132.028.188l-.544.684a.134.134 0 0 1-.187.019c-.056-.047-.075-.131-.028-.188l.544-.684a.134.134 0 0 1 .187-.019"
/>
<path
fill="#fff"
d="M9.944 23.303c.065.047.075.14.028.206l-.544.685c-.047.065-.14.075-.206.018-.066-.046-.075-.14-.028-.206l.544-.684c.046-.066.14-.075.206-.019m-.703.89c.056.047.131.038.178-.018l.544-.684a.133.133 0 0 0-.028-.179c-.057-.047-.132-.037-.179.02l-.543.683c-.038.057-.028.141.028.179"
/>
<path
fill="#F3F3F3"
d="M10.028 21.906c.056.056.056.14 0 .188l-1.387 1.378a.133.133 0 0 1-.188 0c-.046-.047-.056-.14 0-.188l1.378-1.378a.136.136 0 0 1 .197 0"
/>
<path
fill="#fff"
d="M10.037 21.897a.147.147 0 0 1 0 .206L8.65 23.481a.147.147 0 0 1-.207 0 .147.147 0 0 1 0-.206l1.388-1.378a.147.147 0 0 1 .206 0m-1.584 1.575a.13.13 0 0 0 .178 0l1.387-1.378a.13.13 0 0 0 0-.178.13.13 0 0 0-.178 0l-1.387 1.378c-.047.047-.047.122 0 .178"
/>
<path
fill="#F3F3F3"
d="M8.621 21.982c.047.056.047.14-.009.187l-.666.563a.134.134 0 0 1-.187-.02c-.047-.056-.047-.14.01-.187l.665-.562a.134.134 0 0 1 .187.018"
/>
<path
fill="#fff"
d="M8.631 21.972c.057.066.047.16-.009.206l-.666.563c-.056.056-.15.047-.206-.02-.056-.065-.047-.159.01-.206l.665-.562c.056-.056.15-.047.206.019m-.862.74c.047.057.122.057.178.02l.666-.563a.13.13 0 0 0 .009-.178c-.047-.057-.122-.057-.178-.02l-.666.563c-.056.047-.056.122-.01.178"
/>
<path
fill="#F3F3F3"
d="M8.997 20.631a.127.127 0 0 1-.038.188l-1.63 1.087c-.066.038-.15.019-.188-.037a.127.127 0 0 1 .037-.188l1.632-1.087c.056-.038.14-.02.187.037"
/>
<path
fill="#fff"
d="M9.006 20.631a.15.15 0 0 1-.037.207l-1.631 1.087a.144.144 0 0 1-.206-.047.15.15 0 0 1 .037-.206L8.8 20.584a.144.144 0 0 1 .206.047M7.15 21.86a.122.122 0 0 0 .169.037L8.95 20.81c.056-.038.075-.113.028-.179a.122.122 0 0 0-.168-.037L7.178 21.68a.133.133 0 0 0-.028.178"
/>
<path
fill="#F3F3F3"
d="M7.6 20.435a.136.136 0 0 1-.047.187l-.76.431c-.065.038-.15.01-.187-.056a.137.137 0 0 1 .047-.188l.76-.43c.065-.038.15-.01.187.055"
/>
<path
fill="#fff"
d="M7.61 20.425c.037.075.018.16-.047.197l-.76.43a.15.15 0 0 1-.197-.055c-.037-.075-.018-.16.047-.197l.76-.431c.065-.038.16-.01.197.056m-.994.562c.037.066.112.085.169.047l.759-.431a.135.135 0 0 0 .047-.169c-.038-.065-.113-.084-.169-.047l-.76.431c-.056.029-.074.104-.046.17"
/>
<path
fill="#F3F3F3"
d="M8.228 19.187c.028.075 0 .15-.075.178l-1.81.75c-.065.029-.15-.009-.178-.074-.028-.076 0-.15.075-.179l1.81-.75c.065-.028.15 0 .178.075"
/>
<path
fill="#fff"
d="M8.237 19.178c.028.075 0 .16-.075.197l-1.81.75a.146.146 0 0 1-.187-.084.155.155 0 0 1 .075-.197l1.81-.75a.145.145 0 0 1 .187.084m-2.053.853a.133.133 0 0 0 .16.075l1.809-.75a.127.127 0 0 0 .065-.168.134.134 0 0 0-.159-.075l-1.81.75a.127.127 0 0 0-.065.168"
/>
<path
fill="#F3F3F3"
d="M6.906 18.719a.135.135 0 0 1-.084.178l-.825.272c-.066.028-.15-.02-.169-.085-.028-.075.01-.15.085-.178l.825-.272c.065-.028.14.01.168.085"
/>
<path
fill="#fff"
d="M6.915 18.71a.143.143 0 0 1-.094.187l-.825.272c-.075.028-.159-.019-.187-.094a.143.143 0 0 1 .094-.188l.825-.271a.16.16 0 0 1 .187.093m-1.087.356c.019.065.094.103.16.084l.824-.272c.066-.019.094-.094.075-.16-.019-.065-.094-.102-.16-.084l-.824.272a.13.13 0 0 0-.075.16"
/>
<path
fill="#F3F3F3"
d="M7.75 17.603a.126.126 0 0 1-.103.16l-1.922.375c-.075.018-.14-.038-.159-.113a.127.127 0 0 1 .103-.16l1.922-.374c.075-.01.15.037.16.112"
/>
<path
fill="#fff"
d="M7.769 17.603a.15.15 0 0 1-.113.178l-1.922.375c-.075.02-.15-.037-.168-.121a.15.15 0 0 1 .112-.179l1.922-.375c.075-.018.15.038.169.122m-2.185.422c.01.066.075.113.14.103l1.923-.375c.065-.01.112-.084.094-.15A.126.126 0 0 0 7.6 17.5l-1.922.375c-.066.019-.112.085-.094.15"
/>
<path
fill="#F3F3F3"
d="M6.55 16.881a.13.13 0 0 1-.113.15l-.862.104a.136.136 0 0 1-.15-.122.13.13 0 0 1 .112-.15l.863-.104c.066-.009.14.047.15.122"
/>
<path
fill="#fff"
d="M6.56 16.882c.009.084-.048.159-.123.168l-.862.103a.146.146 0 0 1-.16-.131c-.009-.084.047-.16.122-.169l.863-.103c.075-.01.15.047.16.131m-1.135.13c.01.066.075.123.14.113l.863-.103a.126.126 0 0 0 .103-.14c-.01-.066-.075-.122-.14-.113l-.863.103a.126.126 0 0 0-.103.14"
/>
<path
fill="#F3F3F3"
d="M7.6 15.972c0 .075-.057.14-.132.14H5.51c-.075 0-.131-.065-.131-.14 0-.075.056-.14.131-.14h1.96c.074 0 .13.065.13.14"
/>
<path
fill="#fff"
d="M7.61 15.972c0 .084-.067.15-.142.15H5.51c-.075 0-.14-.066-.14-.15 0-.085.065-.15.14-.15h1.96c.075 0 .14.065.14.15m-2.223-.01c0 .066.056.132.122.132h1.96c.065 0 .121-.057.121-.132 0-.065-.056-.13-.122-.13H5.51c-.065.008-.122.065-.122.13"
/>
<path
fill="#E2E2E2"
d="M6.56 15.025c-.01.075-.066.131-.141.131l-.872-.066a.137.137 0 0 1-.122-.15c.01-.075.066-.13.14-.13l.872.065c.066.01.122.075.122.15"
/>
<path
fill="#fff"
d="M6.569 15.025a.152.152 0 0 1-.15.14l-.872-.065a.148.148 0 0 1-.131-.16.152.152 0 0 1 .15-.14l.871.066c.075.009.132.084.132.159m-1.144-.084a.119.119 0 0 0 .112.131l.872.066c.066 0 .122-.047.132-.122a.119.119 0 0 0-.113-.132l-.872-.065c-.065 0-.122.056-.131.122"
/>
<path
fill="#F3F3F3"
d="M7.769 14.34a.144.144 0 0 1-.16.113l-1.921-.394c-.075-.018-.122-.084-.104-.159a.144.144 0 0 1 .16-.112l1.922.384c.065.019.112.094.103.169"
/>
<path
fill="#fff"
d="M7.778 14.34a.153.153 0 0 1-.169.122l-1.922-.384a.15.15 0 0 1-.112-.178.153.153 0 0 1 .168-.122l1.922.384c.075.02.122.094.113.178m-2.185-.44a.134.134 0 0 0 .094.15l1.922.384a.133.133 0 0 0 .15-.103.135.135 0 0 0-.094-.15l-1.921-.384c-.075-.01-.132.037-.15.103"
/>
<path
fill="#F3F3F3"
d="M6.925 13.216a.142.142 0 0 1-.063.086.14.14 0 0 1-.106.017l-.844-.235c-.075-.018-.112-.093-.093-.168a.139.139 0 0 1 .114-.106.141.141 0 0 1 .054.003l.844.234c.075.019.113.094.094.169"
/>
<path
fill="#fff"
d="M6.935 13.216c-.019.075-.103.13-.178.103l-.844-.235a.15.15 0 0 1-.103-.178c.018-.075.103-.131.178-.103l.844.235c.075.018.122.093.103.178m-1.097-.31a.132.132 0 0 0 .084.16l.844.234c.066.019.131-.019.15-.094a.132.132 0 0 0-.084-.16l-.844-.233a.134.134 0 0 0-.15.093"
/>
<path
fill="#F3F3F3"
d="M8.247 12.766a.134.134 0 0 1-.178.075l-1.81-.75c-.066-.028-.103-.113-.075-.178a.134.134 0 0 1 .178-.075l1.81.75a.134.134 0 0 1 .075.178"
/>
<path
fill="#fff"
d="M8.256 12.775a.146.146 0 0 1-.187.085l-1.81-.75c-.075-.029-.103-.113-.075-.197a.145.145 0 0 1 .188-.085l1.81.75a.155.155 0 0 1 .074.197m-2.053-.853c-.028.066 0 .14.066.169l1.81.75c.065.028.13-.01.159-.075a.127.127 0 0 0-.066-.169l-1.81-.75c-.065-.019-.14.01-.159.075"
/>
<path
fill="#F3F3F3"
d="M7.638 11.5c-.038.066-.113.094-.179.066l-.778-.394c-.065-.028-.093-.113-.056-.188.038-.065.113-.094.178-.065l.778.393c.066.038.094.123.057.188"
/>
<path
fill="#fff"
d="M7.647 11.51a.143.143 0 0 1-.197.065l-.778-.394a.143.143 0 0 1-.066-.196.143.143 0 0 1 .197-.066l.778.394c.075.028.103.122.066.196m-1.013-.516c-.028.066-.009.14.057.169l.778.393c.056.029.131 0 .169-.056.028-.066.009-.14-.057-.169l-.787-.393a.116.116 0 0 0-.16.056"
/>
<path
fill="#F3F3F3"
d="M9.015 11.322c-.047.066-.122.084-.187.037l-1.622-1.078c-.066-.037-.075-.122-.038-.187a.127.127 0 0 1 .188-.038l1.631 1.088c.056.037.075.122.028.178"
/>
<path
fill="#fff"
d="M9.025 11.331a.162.162 0 0 1-.206.047l-1.622-1.087a.148.148 0 0 1-.038-.207.162.162 0 0 1 .206-.046l1.622 1.087c.075.047.085.14.038.206m-1.847-1.237c-.038.056-.028.14.028.178l1.631 1.088a.122.122 0 0 0 .169-.038c.037-.056.028-.14-.028-.178l-1.631-1.088a.122.122 0 0 0-.17.038"
/>
<path
fill="#F3F3F3"
d="M8.669 9.962c-.047.057-.131.075-.188.028l-.684-.534a.134.134 0 0 1-.019-.187c.047-.057.132-.075.188-.028l.684.534a.134.134 0 0 1 .019.187"
/>
<path
fill="#fff"
d="M8.678 9.972c-.047.066-.14.075-.206.028l-.685-.534c-.065-.047-.075-.14-.028-.206.047-.066.14-.075.206-.028l.685.534c.065.047.075.14.028.206m-.9-.703c-.047.056-.038.131.019.178l.684.535a.133.133 0 0 0 .178-.029c.047-.056.038-.13-.019-.178l-.684-.534a.133.133 0 0 0-.178.028"
/>
<path
fill="#F3F3F3"
d="M10.065 10.056c-.056.057-.14.057-.187 0L8.48 8.678a.133.133 0 0 1 0-.187c.056-.057.14-.057.187 0l1.388 1.378c.056.047.056.14.01.187"
/>
<path
fill="#fff"
d="M10.066 10.066a.147.147 0 0 1-.206 0L8.472 8.687a.147.147 0 0 1 0-.206.147.147 0 0 1 .206 0l1.388 1.378a.133.133 0 0 1 0 .207M8.49 8.5a.13.13 0 0 0 0 .178l1.387 1.378a.13.13 0 0 0 .178 0 .13.13 0 0 0 0-.178L8.67 8.491c-.047-.047-.122-.047-.178.009"
/>
<path
fill="#F3F3F3"
d="M9.982 8.66c-.057.046-.141.046-.188-.01l-.572-.656a.134.134 0 0 1 .019-.188c.056-.047.14-.047.187.01l.572.656c.047.047.038.131-.018.187"
/>
<path
fill="#fff"
d="M9.99 8.66a.147.147 0 0 1-.206-.01l-.572-.656c-.056-.057-.046-.15.02-.207a.147.147 0 0 1 .205.01l.572.656c.047.066.047.16-.018.206m-.75-.854c-.056.047-.056.122-.018.178l.572.657a.13.13 0 0 0 .178.009c.056-.047.056-.122.019-.178l-.572-.656c-.047-.057-.122-.057-.178-.01"
/>
<path
fill="#F3F3F3"
d="M11.332 9.016a.127.127 0 0 1-.188-.038l-1.097-1.622c-.037-.065-.019-.15.038-.187a.127.127 0 0 1 .187.037l1.088 1.622a.133.133 0 0 1-.028.188"
/>
<path
fill="#fff"
d="M11.331 9.025a.15.15 0 0 1-.206-.037l-1.088-1.622a.144.144 0 0 1 .047-.206.15.15 0 0 1 .206.037l1.088 1.622a.143.143 0 0 1-.047.206m-1.238-1.847a.122.122 0 0 0-.037.17l1.087 1.62c.038.057.113.067.178.029a.122.122 0 0 0 .038-.169l-1.087-1.612a.134.134 0 0 0-.179-.038"
/>
<path
fill="#F3F3F3"
d="M11.528 7.628a.136.136 0 0 1-.188-.047l-.43-.759c-.038-.066-.01-.15.055-.188a.137.137 0 0 1 .188.047l.431.76c.028.065.01.15-.056.187"
/>
<path
fill="#fff"
d="M11.528 7.638c-.075.037-.16.018-.197-.047l-.43-.76a.141.141 0 0 1 .056-.197c.074-.037.159-.018.196.047l.431.76c.038.065.02.15-.056.197m-.562-.994c-.056.037-.084.112-.047.169l.431.759a.134.134 0 0 0 .169.047c.056-.038.084-.113.047-.169l-.432-.76c-.027-.055-.112-.084-.168-.046"
/>
<path
fill="#F3F3F3"
d="M12.775 8.247c-.075.028-.15 0-.178-.066l-.76-1.81c-.027-.065.01-.15.075-.178.075-.028.15 0 .179.066l.759 1.81c.028.065-.01.15-.075.178"
/>
<path
fill="#fff"
d="M12.775 8.256a.155.155 0 0 1-.197-.075l-.759-1.809a.146.146 0 0 1 .084-.187c.075-.029.16 0 .197.075l.76 1.809c.028.065-.01.15-.085.187m-.853-2.053c-.065.028-.094.103-.065.16l.759 1.809a.127.127 0 0 0 .169.066c.065-.029.094-.104.065-.16l-.76-1.81c-.037-.065-.102-.093-.168-.065"
/>
<path
fill="#F3F3F3"
d="M13.234 6.916c-.075.028-.15-.01-.178-.085l-.272-.825c-.028-.065.019-.15.084-.168.066-.02.15.009.178.084l.272.825c.028.066-.01.14-.084.169"
/>
<path
fill="#fff"
d="M13.244 6.925a.146.146 0 0 1-.187-.084l-.272-.825c-.028-.075.018-.16.094-.188a.146.146 0 0 1 .187.085l.272.825a.16.16 0 0 1-.094.187m-.365-1.078a.13.13 0 0 0-.085.16l.272.824c.019.066.094.094.16.075.065-.018.102-.093.084-.159l-.272-.825a.12.12 0 0 0-.111-.084.118.118 0 0 0-.048.01"
/>
<path
fill="#F3F3F3"
d="M14.34 7.769a.127.127 0 0 1-.16-.103l-.393-1.922c-.019-.075.038-.14.113-.16.075-.018.15.029.159.104l.394 1.922a.15.15 0 0 1-.113.159"
/>
<path
fill="#fff"
d="M14.34 7.778a.15.15 0 0 1-.178-.112l-.393-1.922c-.02-.075.037-.16.112-.169a.15.15 0 0 1 .178.113l.394 1.922c.019.075-.028.15-.112.168m-.44-2.184c-.066.01-.113.075-.103.15l.394 1.922c.009.065.084.112.15.094.065-.01.112-.075.103-.15l-.394-1.922a.134.134 0 0 0-.15-.094"
/>
<path
fill="#F3F3F3"
d="M15.053 6.55a.144.144 0 0 1-.16-.112l-.112-.863a.137.137 0 0 1 .122-.15c.075-.01.14.038.16.113l.112.862a.136.136 0 0 1-.122.15"
/>
<path
fill="#fff"
d="M15.054 6.56c-.085.009-.16-.047-.17-.122l-.112-.863a.146.146 0 0 1 .131-.16c.085-.009.16.048.17.123l.112.862a.146.146 0 0 1-.132.16m-.141-1.126c-.066.01-.122.075-.113.141l.113.863c.01.065.075.112.14.103.066-.01.122-.075.113-.14l-.113-.863a.126.126 0 0 0-.14-.104"
/>
<path
fill="url(#Name=safari_svg__c)"
fillOpacity={0.2}
d="M16.066 27.09c6.13 0 11.1-4.97 11.1-11.1s-4.97-11.1-11.1-11.1-11.1 4.97-11.1 11.1 4.97 11.1 11.1 11.1"
/>
<path
fill="#000"
fillOpacity={0.05}
d="M23.753 8.95 14.8 14.688h-.01v.009l-.009.01-5.587 9.215 8.212-6.61.01-.009v-.01z"
/>
<path fill="#CD151E" d="m23.584 8.415-8.85 6.291 2.625 2.606z" />
<path fill="#FA5153" d="m14.744 14.687 1.322 1.303 7.518-7.575z" />
<path fill="#ACACAC" d="m14.743 14.688 2.625 2.606-8.85 6.29z" />
<path fill="#EEE" d="m8.518 23.584 7.547-7.593-1.322-1.303z" />
<defs>
<linearGradient
id="Name=safari_svg__a"
x1={16}
x2={16}
y1={28}
y2={4}
gradientUnits="userSpaceOnUse"
>
<stop offset={0.25} stopColor="#DBDBDA" />
<stop offset={1} stopColor="#fff" />
</linearGradient>
<linearGradient
id="Name=safari_svg__c"
x1={15.855}
x2={10.674}
y1={12.817}
y2={22.112}
gradientUnits="userSpaceOnUse"
>
<stop stopOpacity={0} />
<stop offset={1} />
</linearGradient>
<radialGradient
id="Name=safari_svg__b"
cx={0}
cy={0}
r={1}
gradientTransform="translate(17.56 13.562)scale(13.5491)"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#2ABCE1" />
<stop offset={0.114} stopColor="#2ABBE1" />
<stop offset={1} stopColor="#3375F8" />
</radialGradient>
</defs>
</svg>
);
export default Safari;

View File

@ -0,0 +1,37 @@
import React from "react";
import type { SVGProps } from "react";
const Slack = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" {...props}>
<path
fill="#fff"
stroke="#E2E4EA"
d="M.5 8A7.5 7.5 0 0 1 8 .5h16A7.5 7.5 0 0 1 31.5 8v16a7.5 7.5 0 0 1-7.5 7.5H8A7.5 7.5 0 0 1 .5 24z"
/>
<g fillRule="evenodd" clipPath="url(#Name=slack_svg__a)" clipRule="evenodd">
<path
fill="#36C5F0"
d="M13.066 5a2.204 2.204 0 0 0 .001 4.408h2.2V7.205A2.204 2.204 0 0 0 13.067 5m0 5.878H7.2A2.202 2.202 0 0 0 5 13.082a2.202 2.202 0 0 0 2.2 2.205h5.866a2.202 2.202 0 0 0 2.2-2.204 2.202 2.202 0 0 0-2.2-2.205"
/>
<path
fill="#2EB67D"
d="M27 13.082a2.202 2.202 0 0 0-2.2-2.204 2.202 2.202 0 0 0-2.2 2.204v2.205h2.2a2.202 2.202 0 0 0 2.2-2.205m-5.867 0V7.204A2.203 2.203 0 0 0 18.933 5a2.202 2.202 0 0 0-2.2 2.204v5.878a2.202 2.202 0 0 0 2.2 2.205 2.202 2.202 0 0 0 2.2-2.205"
/>
<path
fill="#ECB22E"
d="M18.933 27.044a2.202 2.202 0 0 0 2.2-2.204 2.202 2.202 0 0 0-2.2-2.204h-2.2v2.204a2.203 2.203 0 0 0 2.2 2.204m0-5.88H24.8a2.202 2.202 0 0 0 2.2-2.203 2.202 2.202 0 0 0-2.2-2.205h-5.866a2.202 2.202 0 0 0-2.2 2.204 2.202 2.202 0 0 0 2.199 2.205"
/>
<path
fill="#E01E5A"
d="M5 18.96c0 1.217.984 2.204 2.2 2.205a2.202 2.202 0 0 0 2.2-2.204v-2.204H7.2A2.202 2.202 0 0 0 5 18.96m5.867 0v5.88a2.202 2.202 0 0 0 2.2 2.204 2.202 2.202 0 0 0 2.2-2.204v-5.877a2.2 2.2 0 1 0-4.4-.002"
/>
</g>
<defs>
<clipPath id="Name=slack_svg__a">
<path fill="#fff" d="M5 5h22v22.044H5z" />
</clipPath>
</defs>
</svg>
);
export default Slack;

View File

@ -0,0 +1,64 @@
import React, { ComponentType, SVGProps } from "react";
import {
SOFTWARE_NAME_TO_ICON_MAP,
SOFTWARE_SOURCE_TO_ICON_MAP,
SOFTWARE_ICON_SIZES,
SoftwareIconSizes,
} from "../";
const baseClass = "software-icon";
interface ISoftwareIconProps {
name?: string;
source?: string;
size?: SoftwareIconSizes;
}
const matchInMap = (
map: Record<string, ComponentType<SVGProps<SVGSVGElement>>>,
potentialKey?: string
) => {
if (!potentialKey) {
return null;
}
const sanitizedKey = potentialKey.trim().toLowerCase();
const match = Object.entries(map).find(([namePrefix, icon]) => {
if (sanitizedKey.startsWith(namePrefix)) {
return icon;
}
return null;
});
return match ? match[1] : null;
};
const SoftwareIcon = ({
name,
source,
size = "medium",
}: ISoftwareIconProps) => {
// try to find a match for name
let MatchedIcon = matchInMap(SOFTWARE_NAME_TO_ICON_MAP, name);
// otherwise, try to find a match for source
if (!MatchedIcon) {
MatchedIcon = matchInMap(SOFTWARE_SOURCE_TO_ICON_MAP, source);
}
// default to 'package'
if (!MatchedIcon) {
MatchedIcon = SOFTWARE_SOURCE_TO_ICON_MAP.package;
}
return (
<MatchedIcon
width={SOFTWARE_ICON_SIZES[size]}
height={SOFTWARE_ICON_SIZES[size]}
viewBox="0 0 32 32"
className={baseClass}
/>
);
};
export default SoftwareIcon;

View File

@ -0,0 +1 @@
export { default } from "./SoftwareIcon";

View File

@ -0,0 +1,84 @@
import React from "react";
import type { SVGProps } from "react";
const Teams = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" {...props}>
<path
fill="#fff"
stroke="#E2E4EA"
d="M.5 8A7.5 7.5 0 0 1 8 .5h16A7.5 7.5 0 0 1 31.5 8v16a7.5 7.5 0 0 1-7.5 7.5H8A7.5 7.5 0 0 1 .5 24z"
/>
<path
fill="#5059C9"
d="M20.043 14.009h5.94c.562 0 1.017.453 1.017 1.014v5.401a3.732 3.732 0 0 1-3.736 3.73h-.016a3.732 3.732 0 0 1-3.736-3.73v-5.883c0-.294.238-.532.53-.532m4.016-1.068a2.405 2.405 0 0 0 2.405-2.402 2.405 2.405 0 0 0-2.406-2.403 2.405 2.405 0 0 0-2.407 2.403 2.405 2.405 0 0 0 2.407 2.402z"
/>
<path
fill="#7B83EB"
d="M16.57 12.941a3.473 3.473 0 0 0 3.212-4.8A3.474 3.474 0 0 0 16.568 6a3.473 3.473 0 0 0-3.476 3.47 3.473 3.473 0 0 0 3.476 3.471zm4.636 1.068h-9.808a1.004 1.004 0 0 0-.98 1.027v6.16a6.027 6.027 0 0 0 5.884 6.161 6.027 6.027 0 0 0 5.884-6.16v-6.161a1.004 1.004 0 0 0-.98-1.027"
/>
<path
fill="#000"
d="M16.837 14.009v8.633a.982.982 0 0 1-.979.978h-4.97a6.491 6.491 0 0 1-.471-2.425v-6.16a1 1 0 0 1 .98-1.024h5.439z"
opacity={0.1}
/>
<path
fill="#000"
d="M16.302 14.009v9.167a.985.985 0 0 1-.98.978h-4.18a5.293 5.293 0 0 1-.44-1.068 6.49 6.49 0 0 1-.282-1.89v-6.164a1 1 0 0 1 .98-1.025h4.903z"
opacity={0.2}
/>
<path
fill="#000"
d="M16.302 14.009v8.1a.985.985 0 0 1-.98.977h-4.62a6.49 6.49 0 0 1-.282-1.89v-6.164a1 1 0 0 1 .98-1.025h4.903z"
opacity={0.2}
/>
<path
fill="#000"
d="M15.768 14.009v8.1a.986.986 0 0 1-.98.977h-4.087a6.493 6.493 0 0 1-.282-1.89v-6.164a1.004 1.004 0 0 1 .979-1.025z"
opacity={0.2}
/>
<path
fill="#000"
d="M16.837 11.249v1.682c-.09.005-.175.01-.267.01-.09 0-.176-.005-.268-.01a3.475 3.475 0 0 1-3.113-2.66h2.67a.982.982 0 0 1 .978.976z"
opacity={0.1}
/>
<path
fill="#000"
d="M16.302 11.783v1.148a3.475 3.475 0 0 1-2.941-2.124h1.963a.982.982 0 0 1 .978.976"
opacity={0.2}
/>
<path
fill="#000"
d="M16.302 11.783v1.148a3.475 3.475 0 0 1-2.941-2.124h1.963a.982.982 0 0 1 .978.976"
opacity={0.2}
/>
<path
fill="#000"
d="M15.768 11.783v1.063a3.474 3.474 0 0 1-2.407-2.04h1.43a.982.982 0 0 1 .977.977"
opacity={0.2}
/>
<path
fill="url(#Name=teams_svg__a)"
d="M4.98 10.805h9.807a.98.98 0 0 1 .98.98v9.788a.98.98 0 0 1-.98.979H4.979a.98.98 0 0 1-.979-.98v-9.787c0-.543.439-.98.98-.98"
/>
<path
fill="#fff"
d="M12.464 14.531h-1.96v5.328H9.256v-5.328H7.302v-1.035h5.16z"
/>
<defs>
<linearGradient
id="Name=teams_svg__a"
x1={6.044}
x2={13.703}
y1={10.041}
y2={23.329}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#5A62C3" />
<stop offset={0.5} stopColor="#4D55BD" />
<stop offset={1} stopColor="#3940AB" />
</linearGradient>
</defs>
</svg>
);
export default Teams;

View File

@ -0,0 +1,37 @@
import React from "react";
import type { SVGProps } from "react";
const VisualStudioCode = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" {...props}>
<path
fill="#fff"
stroke="#E2E4EA"
d="M.5 8A7.5 7.5 0 0 1 8 .5h16A7.5 7.5 0 0 1 31.5 8v16a7.5 7.5 0 0 1-7.5 7.5H8A7.5 7.5 0 0 1 .5 24z"
/>
<g clipPath="url(#Name=vsc_svg__a)">
<path
fill="#2489CA"
d="M5.64 12.864s-.53-.382.106-.892l1.483-1.326s.424-.447.873-.058l13.683 10.36v4.967s-.007.78-1.008.694z"
/>
<path
fill="#1070B3"
d="M9.167 16.066 5.64 19.273s-.362.27 0 .75l1.638 1.49s.389.418.963-.057l3.739-2.835z"
/>
<path
fill="#0877B9"
d="m15.359 16.093 6.467-4.939-.041-4.94s-.277-1.08-1.198-.518L11.98 13.53z"
/>
<path
fill="#3C99D4"
d="M20.777 26.616c.375.384.83.258.83.258l5.041-2.484c.645-.44.555-.985.555-.985V8.573c0-.652-.668-.877-.668-.877L22.167 5.59c-.955-.59-1.58.106-1.58.106s.804-.579 1.198.517v19.611a.89.89 0 0 1-.087.387c-.115.232-.364.449-.963.358z"
/>
</g>
<defs>
<clipPath id="Name=vsc_svg__a">
<path fill="#fff" d="M5 5h22.403v22H5z" />
</clipPath>
</defs>
</svg>
);
export default VisualStudioCode;

View File

@ -0,0 +1,20 @@
import React from "react";
import type { SVGProps } from "react";
const WindowsApp = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" {...props}>
<path
fill="#F9FAFC"
stroke="#E2E4EA"
d="M.5 8A7.5 7.5 0 0 1 8 .5h16A7.5 7.5 0 0 1 31.5 8v16a7.5 7.5 0 0 1-7.5 7.5H8A7.5 7.5 0 0 1 .5 24z"
/>
<path
fill="#0078D4"
fillRule="evenodd"
d="M14.522 15.58H25V6L14.522 8.096zm-1.033.001H7V9.593l6.49-1.297zm0 8.143L7 22.428V16.4h6.49zM25 26l-10.478-2.076V16.4H25z"
clipRule="evenodd"
/>
</svg>
);
export default WindowsApp;

View File

@ -0,0 +1,94 @@
import React from "react";
import type { SVGProps } from "react";
const Word = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" {...props}>
<path
fill="#fff"
stroke="#E2E4EA"
d="M.5 8A7.5 7.5 0 0 1 8 .5h16A7.5 7.5 0 0 1 31.5 8v16a7.5 7.5 0 0 1-7.5 7.5H8A7.5 7.5 0 0 1 .5 24z"
/>
<path
fill="url(#Name=word_svg__a)"
d="M24.625 6.5h-13.75c-.76 0-1.375.616-1.375 1.375v16.5c0 .76.616 1.375 1.375 1.375h13.75c.76 0 1.375-.616 1.375-1.375v-16.5c0-.76-.616-1.375-1.375-1.375"
/>
<path
fill="url(#Name=word_svg__b)"
d="M9.5 20.938H26v3.437c0 .76-.616 1.375-1.375 1.375h-13.75c-.76 0-1.375-.616-1.375-1.375z"
/>
<path fill="url(#Name=word_svg__c)" d="M26 16.125H9.5v4.813H26z" />
<path fill="url(#Name=word_svg__d)" d="M26 11.313H9.5v4.812H26z" />
<path
fill="#000"
fillOpacity={0.3}
d="M9.5 13.375c0-1.14.923-2.062 2.063-2.062h4.124c1.14 0 2.063.923 2.063 2.062v8.25c0 1.14-.923 2.063-2.062 2.063H9.5z"
/>
<path
fill="url(#Name=word_svg__e)"
d="M15 9.938H5.375c-.76 0-1.375.615-1.375 1.374v9.626c0 .759.616 1.375 1.375 1.375H15c.76 0 1.375-.616 1.375-1.375v-9.625c0-.76-.616-1.376-1.375-1.376"
/>
<path
fill="#fff"
d="M14.313 12.697h-1.34l-1.051 4.486-1.15-4.495H9.639L8.48 17.183l-1.043-4.486H6.063l1.789 6.866h1.186l1.15-4.34 1.15 4.34h1.187z"
/>
<defs>
<linearGradient
id="Name=word_svg__a"
x1={9.5}
x2={26}
y1={9.708}
y2={9.708}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#2B78B1" />
<stop offset={1} stopColor="#338ACD" />
</linearGradient>
<linearGradient
id="Name=word_svg__b"
x1={9.5}
x2={26}
y1={23.945}
y2={23.945}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#1B366F" />
<stop offset={1} stopColor="#2657B0" />
</linearGradient>
<linearGradient
id="Name=word_svg__c"
x1={16.719}
x2={26}
y1={18.875}
y2={18.875}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#20478B" />
<stop offset={1} stopColor="#2D6FD1" />
</linearGradient>
<linearGradient
id="Name=word_svg__d"
x1={16.719}
x2={26}
y1={14.063}
y2={14.063}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#215295" />
<stop offset={1} stopColor="#2E84D3" />
</linearGradient>
<linearGradient
id="Name=word_svg__e"
x1={4}
x2={17.063}
y1={16.813}
y2={16.813}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#223E74" />
<stop offset={1} stopColor="#215091" />
</linearGradient>
</defs>
</svg>
);
export default Word;

View File

@ -0,0 +1,30 @@
import React from "react";
import type { SVGProps } from "react";
const Zoom = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" {...props}>
<path
fill="url(#Name=zoom_svg__a)"
d="M0 8a8 8 0 0 1 8-8h16a8 8 0 0 1 8 8v16a8 8 0 0 1-8 8H8a8 8 0 0 1-8-8z"
/>
<path
fill="#EEE"
d="M9.548 18.46h-3.97a.69.69 0 0 1-.508-1.178l2.762-2.762H5.853a.985.985 0 0 1-.986-.986h3.658a.69.69 0 0 1 .508 1.178l-2.762 2.763h2.291c.545 0 .986.44.986.986m15.715-5c-.569 0-1.08.246-1.43.64a1.917 1.917 0 0 0-1.43-.64c-1.057 0-1.918.902-1.918 1.955v3.046a.985.985 0 0 0 .986-.986v-2.07c0-.508.393-.939.898-.959a.938.938 0 0 1 .973.936v2.093c0 .545.44.986.986.986v-3.056c0-.508.393-.939.898-.959a.938.938 0 0 1 .973.936v2.093c0 .545.44.986.985.986v-3.046c-.003-1.053-.864-1.955-1.92-1.955m-10.741 2.537a2.54 2.54 0 0 1-2.537 2.538 2.54 2.54 0 0 1-2.538-2.538 2.54 2.54 0 0 1 2.538-2.537 2.541 2.541 0 0 1 2.537 2.537m-.986 0c0-.854-.697-1.551-1.551-1.551-.855 0-1.552.697-1.552 1.551 0 .855.697 1.552 1.552 1.552.854 0 1.55-.697 1.55-1.552m6.454 0a2.54 2.54 0 0 1-2.537 2.538 2.54 2.54 0 0 1-2.538-2.538c0-1.4 1.141-2.537 2.538-2.537a2.541 2.541 0 0 1 2.537 2.537m-.986 0c0-.854-.697-1.551-1.551-1.551-.855 0-1.552.697-1.552 1.551 0 .855.697 1.552 1.552 1.552.855 0 1.551-.697 1.551-1.552"
/>
<defs>
<linearGradient
id="Name=zoom_svg__a"
x1={33.5}
x2={0}
y1={0}
y2={32}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#3A8FEA" />
<stop offset={1} stopColor="#0042BC" />
</linearGradient>
</defs>
</svg>
);
export default Zoom;

View File

@ -0,0 +1,60 @@
import AcrobatReader from "./AcrobatReader";
import Chrome from "./Chrome";
import Excel from "./Excel";
import Extension from "./Extension";
import Firefox from "./Firefox";
import MacApp from "./MacApp";
import Package from "./Package";
import Safari from "./Safari";
import Slack from "./Slack";
import Teams from "./Teams";
import VisualStudioCode from "./VisualStudioCode";
import WindowsApp from "./WindowsApp";
import Word from "./Word";
import Zoom from "./Zoom";
// SOFTWARE_NAME_TO_ICON_MAP list "special" applications that have a defined
// icon for them, keys refer to application names, and are intended to be fuzzy
// matched in the application logic.
export const SOFTWARE_NAME_TO_ICON_MAP = {
"adobe acrobat reader": AcrobatReader,
"google chrome": Chrome,
"microsoft excel": Excel,
firefox: Firefox,
package: Package,
safari: Safari,
slack: Slack,
"microsoft teams": Teams,
"visual studio code": VisualStudioCode,
"microsoft word": Word,
zoom: Zoom,
} as const;
// SOFTWARE_SOURCE_TO_ICON_MAP maps different software sources to a defined
// icon.
export const SOFTWARE_SOURCE_TO_ICON_MAP = {
package: Package,
apt_sources: Package,
deb_packages: Package,
rpm_packages: Package,
yum_sources: Package,
npm_packages: Package,
atom_packages: Package,
python_packages: Package,
homebrew_packages: Package,
apps: MacApp,
programs: WindowsApp,
chrome_extensions: Extension,
safari_extensions: Extension,
firefox_addons: Extension,
ie_extensions: Extension,
chocolatey_packages: Package,
pkg_packages: Package,
} as const;
export const SOFTWARE_ICON_SIZES: Record<string, string> = {
medium: "24",
large: "96",
} as const;
export type SoftwareIconSizes = keyof typeof SOFTWARE_ICON_SIZES;

View File

View File

@ -0,0 +1 @@
export { default } from "./SoftwarePage";

View File

@ -50,7 +50,7 @@ const WindowsAutomaticEnrollmentPage = () => {
<CustomLink
newTab
text="Sign in to Azure portal"
url="portal.azure.com"
url="https://fleetdm.com/sign-in-to/microsoft-automatic-enrollment-tool"
/>
</li>
<li>

View File

@ -10,6 +10,16 @@
display: flex;
justify-content: space-between;
align-items: center;
p {
margin-right: $pad-small;
}
button {
.children-wrapper {
text-wrap: nowrap;
}
}
}
&__turn-on-mac-os {
@ -25,7 +35,7 @@
}
&__turn-off-mac-os {
>div {
> div {
display: flex;
align-items: center;
}

View File

@ -5,10 +5,21 @@
margin: 0;
}
&__turn-on-windows, &__turn-off-windows {
&__turn-on-windows,
&__turn-off-windows {
display: flex;
justify-content: space-between;
align-items: center;
p {
margin-right: $pad-small;
}
button {
.children-wrapper {
text-wrap: nowrap;
}
}
}
&__turn-on-windows {

View File

@ -219,6 +219,14 @@ const ManageHostsPage = ({
queryParams?.software_id !== undefined
? parseInt(queryParams.software_id, 10)
: undefined;
const softwareVersionId =
queryParams?.software_version_id !== undefined
? parseInt(queryParams.software_version_id, 10)
: undefined;
const softwareTitleId =
queryParams?.software_title_id !== undefined
? parseInt(queryParams.software_title_id, 10)
: undefined;
const status = isAcceptableStatus(queryParams?.status)
? queryParams?.status
: undefined;
@ -360,6 +368,8 @@ const ManageHostsPage = ({
policyId,
policyResponse,
softwareId,
softwareTitleId,
softwareVersionId,
status,
mdmId,
mdmEnrollmentStatus,
@ -400,6 +410,8 @@ const ManageHostsPage = ({
policyId,
policyResponse,
softwareId,
softwareTitleId,
softwareVersionId,
status,
mdmId,
mdmEnrollmentStatus,
@ -491,7 +503,13 @@ const ManageHostsPage = ({
// TODO: cleanup this effect
useEffect(() => {
if (location.search.includes("software_id")) {
if (
location.search.match(
/software_id|software_version_id|software_title_id/gi
)
) {
// regex matches any of "software_id", "software_version_id", or "software_title_id"
// so we don't set the filtered hosts path in those cases
return;
}
const path = location.pathname + location.search;
@ -520,6 +538,8 @@ const ManageHostsPage = ({
"policy_id",
"policy_response",
"software_id",
"software_version_id",
"software_title_id",
]);
}
@ -783,6 +803,10 @@ const ManageHostsPage = ({
newQueryParams.macos_settings = macSettingsStatus;
} else if (softwareId) {
newQueryParams.software_id = softwareId;
} else if (softwareVersionId) {
newQueryParams.software_version_id = softwareVersionId;
} else if (softwareTitleId) {
newQueryParams.software_title_id = softwareTitleId;
} else if (mdmId) {
newQueryParams.mdm_id = mdmId;
} else if (mdmEnrollmentStatus) {
@ -828,6 +852,8 @@ const ManageHostsPage = ({
policyResponse,
macSettingsStatus,
softwareId,
softwareVersionId,
softwareTitleId,
mdmId,
mdmEnrollmentStatus,
munkiIssueId,
@ -1244,6 +1270,8 @@ const ManageHostsPage = ({
policyResponse,
macSettingsStatus,
softwareId,
softwareTitleId,
softwareVersionId,
status,
mdmId,
mdmEnrollmentStatus,
@ -1557,6 +1585,8 @@ const ManageHostsPage = ({
policy,
macSettingsStatus,
softwareId,
softwareTitleId,
softwareVersionId,
mdmId,
mdmEnrollmentStatus,
lowDiskSpaceHosts,
@ -1566,7 +1596,8 @@ const ManageHostsPage = ({
osVersions,
munkiIssueId,
munkiIssueDetails: hostsData?.munki_issue || null,
softwareDetails: hostsData?.software || null,
softwareDetails:
hostsData?.software || hostsData?.software_title || null,
mdmSolutionDetails:
hostsData?.mobile_device_management_solution || null,
osSettingsStatus,

View File

@ -53,7 +53,9 @@ interface IHostsFilterBlockProps {
policyId?: any;
policy?: IPolicy;
macSettingsStatus?: any;
softwareId?: any;
softwareId?: number;
softwareTitleId?: number;
softwareVersionId?: number;
mdmId?: number;
mdmEnrollmentStatus?: any;
lowDiskSpaceHosts?: number;
@ -62,7 +64,7 @@ interface IHostsFilterBlockProps {
osVersion?: any;
munkiIssueId?: number;
osVersions?: IOperatingSystemVersion[];
softwareDetails: ISoftware | null;
softwareDetails: { name: string; version?: string } | null;
mdmSolutionDetails: IMdmSolution | null;
osSettingsStatus?: MdmProfileStatus;
diskEncryptionStatus?: DiskEncryptionStatus;
@ -95,6 +97,8 @@ const HostsFilterBlock = ({
policyId,
macSettingsStatus,
softwareId,
softwareTitleId,
softwareVersionId,
mdmId,
mdmEnrollmentStatus,
lowDiskSpaceHosts,
@ -235,21 +239,31 @@ const HostsFilterBlock = ({
if (!softwareDetails) return null;
const { name, version } = softwareDetails;
const label = `${name || "Unknown software"} ${version || ""}`;
let label = name;
if (version) {
label += ` ${version}`;
}
label = label.trim() || "Unknown software";
const TooltipDescription = (
<span>
Hosts with {name || "Unknown software"},
<br />
{version || "version unknown"} installed
</span>
);
// const TooltipDescription = (
// <span>
// Hosts with {name || "Unknown software"},
// <br />
// {version || "version unknown"} installed
// </span>
// );
return (
<FilterPill
label={label}
onClear={() => handleClearFilter(["software_id"])}
tooltipDescription={TooltipDescription}
onClear={() =>
handleClearFilter([
"software_id",
"software_version_id",
"software_title_id",
])
}
// tooltipDescription={TooltipDescription}
/>
);
};
@ -433,6 +447,8 @@ const HostsFilterBlock = ({
policyId ||
macSettingsStatus ||
softwareId ||
softwareTitleId ||
softwareVersionId ||
mdmId ||
mdmEnrollmentStatus ||
lowDiskSpaceHosts ||
@ -472,7 +488,7 @@ const HostsFilterBlock = ({
return renderPoliciesFilterBlock();
case !!macSettingsStatus:
return renderMacSettingsStatusFilterBlock();
case !!softwareId:
case !!softwareId || !!softwareVersionId || !!softwareTitleId:
return renderSoftwareFilterBlock();
case !!mdmId:
return renderMDMSolutionFilterBlock();

View File

@ -58,9 +58,6 @@ const DiskEncryptionKeyModal = ({
const recoveryText = isMacOS
? "Use this key to log in to the host if you forgot the password."
: "Use this key to unlock the encrypted drive.";
const recoveryUrl = isMacOS
? "https://fleetdm.com/docs/using-fleet/mdm-disk-encryption#reset-a-macos-hosts-password-using-the-disk-encryption-key"
: "https://fleetdm.com/docs/using-fleet/mdm-disk-encryption#unlock-a-windows-hosts-drive-using-the-disk-encryption-key";
return (
<Modal title="Disk encryption key" onExit={onCancel} className={baseClass}>
@ -70,14 +67,7 @@ const DiskEncryptionKeyModal = ({
<>
<InputFieldHiddenContent value={encrpytionKey ?? ""} />
<p>{descriptionText}</p>
<p>
{recoveryText}{" "}
<CustomLink
text="View recovery instructions"
url={recoveryUrl}
newTab
/>
</p>
<p>{recoveryText} </p>
<div className="modal-cta-wrap">
<Button onClick={onCancel}>Done</Button>
</div>

View File

@ -9,6 +9,18 @@
line-height: 1.5;
}
.table-container {
.name__header {
width: 50%;
}
.last_execution__header {
width: 25%;
}
.actions__header {
width: 25%;
}
}
.table-container__header-left {
display: block;
}

View File

@ -13,7 +13,7 @@ import { buildQueryStringFromParams } from "utilities/url";
import Dropdown from "components/forms/fields/Dropdown";
import TableContainer from "components/TableContainer";
import { ITableQueryData } from "components/TableContainer/TableContainer";
import EmptySoftwareTable from "pages/software/components/EmptySoftwareTable";
import EmptySoftwareTable from "pages/SoftwarePage/components/EmptySoftwareTable";
import { getNextLocationPath } from "utilities/helpers";
import SoftwareVulnCount from "./SoftwareVulnCount";

View File

@ -211,12 +211,12 @@ export const generateSoftwareTableHeaders = ({
// Allows for button to be clickable in a clickable row
e.stopPropagation();
setFilteredSoftwarePath(pathname);
router?.push(PATHS.SOFTWARE_DETAILS(id.toString()));
router?.push(PATHS.SOFTWARE_VERSION_DETAILS(id.toString()));
};
return (
<LinkCell
path={PATHS.SOFTWARE_DETAILS(id.toString())}
path={PATHS.SOFTWARE_VERSION_DETAILS(id.toString())}
customOnClick={onClickSoftware}
value={name}
tooltipContent={

View File

@ -1,725 +0,0 @@
import React, {
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { Row } from "react-table";
import PATHS from "router/paths";
import { useQuery } from "react-query";
import { InjectedRouter } from "react-router/lib/Router";
import { RouteProps } from "react-router/lib/Route";
import { isEmpty, isEqual } from "lodash";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import useTeamIdParam from "hooks/useTeamIdParam";
import {
IConfig,
CONFIG_DEFAULT_RECENT_VULNERABILITY_MAX_AGE_IN_DAYS,
} from "interfaces/config";
import {
IJiraIntegration,
IZendeskIntegration,
IIntegrations,
} from "interfaces/integration";
import { ISoftwareResponse, ISoftwareCountResponse } from "interfaces/software";
import { ITeamConfig } from "interfaces/team";
import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook";
import configAPI from "services/entities/config";
import softwareAPI, {
ISoftwareCountQueryKey,
ISoftwareQueryKey,
} from "services/entities/software";
import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
import {
GITHUB_NEW_ISSUE_LINK,
VULNERABLE_DROPDOWN_OPTIONS,
} from "utilities/constants";
import { buildQueryStringFromParams } from "utilities/url";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
import TableDataError from "components/DataError";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import LastUpdatedText from "components/LastUpdatedText";
import MainContent from "components/MainContent";
import TableContainer from "components/TableContainer";
import { ITableQueryData } from "components/TableContainer/TableContainer";
import TeamsDropdown from "components/TeamsDropdown";
import { getNextLocationPath } from "utilities/helpers";
import EmptySoftwareTable from "../components/EmptySoftwareTable";
import generateSoftwareTableHeaders from "./SoftwareTableConfig";
import ManageAutomationsModal from "./components/ManageAutomationsModal";
interface IManageSoftwarePageProps {
route: RouteProps;
router: InjectedRouter;
location: {
pathname: string;
query: {
team_id?: string;
vulnerable?: string;
page?: string;
query?: string;
order_key?: string;
order_direction?: "asc" | "desc";
};
search: string;
};
}
interface ISoftwareConfigQueryKey {
scope: string;
teamId?: number;
}
interface ISoftwareAutomations {
webhook_settings: {
vulnerabilities_webhook: IWebhookSoftwareVulnerabilities;
};
integrations: {
jira: IJiraIntegration[];
zendesk: IZendeskIntegration[];
};
}
interface ISoftwareRowProps extends Row {
original: {
id?: number;
};
}
const DEFAULT_SORT_DIRECTION = "desc";
const DEFAULT_PAGE_SIZE = 20;
const baseClass = "manage-software-page";
const ManageSoftwarePage = ({
route,
router,
location,
}: IManageSoftwarePageProps): JSX.Element => {
const routeTemplate = route?.path ?? "";
const queryParams = location.query;
const {
config: globalConfig,
isFreeTier,
isGlobalAdmin,
isGlobalMaintainer,
isOnGlobalTeam,
isPremiumTier,
isSandboxMode,
noSandboxHosts,
filteredSoftwarePath,
setFilteredSoftwarePath,
} = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
const {
currentTeamId,
isAnyTeamSelected,
isRouteOk,
teamIdForApi,
userTeams,
handleTeamChange,
} = useTeamIdParam({
location,
router,
includeAllTeams: true,
includeNoTeam: false,
});
const canManageAutomations =
isGlobalAdmin && (!isPremiumTier || !isAnyTeamSelected);
const DEFAULT_SORT_HEADER = isPremiumTier ? "vulnerabilities" : "hosts_count";
const initialQuery = (() => {
let query = "";
if (queryParams && queryParams.query) {
query = queryParams.query;
}
return query;
})();
const initialSortHeader = (() => {
let sortHeader = isPremiumTier ? "vulnerabilities" : "hosts_count";
if (queryParams && queryParams.order_key) {
sortHeader = queryParams.order_key;
}
return sortHeader;
})();
const initialSortDirection = ((): "asc" | "desc" | undefined => {
let sortDirection = "desc";
if (queryParams && queryParams.order_direction) {
sortDirection = queryParams.order_direction;
}
return sortDirection as "asc" | "desc" | undefined;
})();
const initialPage = (() => {
let page = 0;
if (queryParams && queryParams.page) {
page = parseInt(queryParams.page, 10);
}
return page;
})();
const initialVulnFilter = (() => {
let isFilteredByVulnerabilities = false;
if (queryParams && queryParams.vulnerable === "true") {
isFilteredByVulnerabilities = true;
}
return isFilteredByVulnerabilities;
})();
const [filterVuln, setFilterVuln] = useState(initialVulnFilter);
const [searchQuery, setSearchQuery] = useState(initialQuery);
const [sortDirection, setSortDirection] = useState<
"asc" | "desc" | undefined
>(initialSortDirection);
const [sortHeader, setSortHeader] = useState(initialSortHeader);
const [page, setPage] = useState(initialPage);
const [tableQueryData, setTableQueryData] = useState<ITableQueryData>();
const [resetPageIndex, setResetPageIndex] = useState<boolean>(false);
const [showManageAutomationsModal, setShowManageAutomationsModal] = useState(
false
);
const [showPreviewPayloadModal, setShowPreviewPayloadModal] = useState(false);
const [showPreviewTicketModal, setShowPreviewTicketModal] = useState(false);
useEffect(() => {
setFilterVuln(initialVulnFilter);
setPage(initialPage);
setSearchQuery(initialQuery);
// TODO: handle invalid values for params
}, [location]);
useEffect(() => {
const path = location.pathname + location.search;
if (filteredSoftwarePath !== path) {
setFilteredSoftwarePath(path);
}
}, [filteredSoftwarePath, location, setFilteredSoftwarePath]);
// softwareConfig is either the global config or the team config of the currently selected team
const {
data: softwareConfig,
error: softwareConfigError,
isFetching: isFetchingSoftwareConfig,
refetch: refetchSoftwareConfig,
} = useQuery<
IConfig | ILoadTeamResponse,
Error,
IConfig | ITeamConfig,
ISoftwareConfigQueryKey[]
>(
[{ scope: "softwareConfig", teamId: teamIdForApi }],
({ queryKey }) => {
const { teamId } = queryKey[0];
return teamId ? teamsAPI.load(teamId) : configAPI.loadAll();
},
{
enabled: isRouteOk,
select: (data) => ("team" in data ? data.team : data),
}
);
const isSoftwareConfigLoaded =
!isFetchingSoftwareConfig && !softwareConfigError && !!softwareConfig;
const isSoftwareEnabled = !!softwareConfig?.features
?.enable_software_inventory;
const vulnWebhookSettings =
softwareConfig?.webhook_settings?.vulnerabilities_webhook;
const isVulnWebhookEnabled = !!vulnWebhookSettings?.enable_vulnerabilities_webhook;
const isVulnIntegrationEnabled = (integrations?: IIntegrations) => {
return (
!!integrations?.jira?.some((j) => j.enable_software_vulnerabilities) ||
!!integrations?.zendesk?.some((z) => z.enable_software_vulnerabilities)
);
};
const isAnyVulnAutomationEnabled =
isVulnWebhookEnabled ||
isVulnIntegrationEnabled(softwareConfig?.integrations);
const recentVulnerabilityMaxAge = (() => {
let maxAgeInNanoseconds: number | undefined;
if (softwareConfig && "vulnerabilities" in softwareConfig) {
maxAgeInNanoseconds =
softwareConfig.vulnerabilities.recent_vulnerability_max_age;
} else {
maxAgeInNanoseconds =
globalConfig?.vulnerabilities.recent_vulnerability_max_age;
}
return maxAgeInNanoseconds
? Math.round(maxAgeInNanoseconds / 86400000000000) // convert from nanoseconds to days
: CONFIG_DEFAULT_RECENT_VULNERABILITY_MAX_AGE_IN_DAYS;
})();
const {
data: software,
error: softwareError,
isFetching: isFetchingSoftware,
} = useQuery<
ISoftwareResponse,
Error,
ISoftwareResponse,
ISoftwareQueryKey[]
>(
[
{
scope: "software",
page: tableQueryData?.pageIndex,
perPage: DEFAULT_PAGE_SIZE,
query: searchQuery,
orderDirection: sortDirection,
// API expects "epss_probability" rather than "vulnerabilities"
orderKey:
isPremiumTier && sortHeader === "vulnerabilities"
? "epss_probability"
: sortHeader,
teamId: teamIdForApi,
vulnerable: filterVuln,
},
],
({ queryKey }) => softwareAPI.load(queryKey[0]),
{
enabled: isRouteOk && isSoftwareConfigLoaded,
keepPreviousData: true,
staleTime: 30000, // stale time can be adjusted if fresher data is desired based on software inventory interval
}
);
const {
data: softwareCount,
error: softwareCountError,
isFetching: isFetchingCount,
} = useQuery<ISoftwareCountResponse, Error, number, ISoftwareCountQueryKey[]>(
[
{
scope: "softwareCount",
query: searchQuery,
vulnerable: filterVuln,
teamId: teamIdForApi,
},
],
({ queryKey }) => softwareAPI.getCount(queryKey[0]),
{
enabled: isRouteOk && isSoftwareConfigLoaded,
keepPreviousData: true,
staleTime: 30000, // stale time can be adjusted if fresher data is desired based on software inventory interval
refetchOnWindowFocus: false,
retry: 1,
select: (data) => data.count,
}
);
// NOTE: this is called once on initial render and every time the query changes
const onQueryChange = useCallback(
async (newTableQuery: ITableQueryData) => {
if (!isRouteOk || isEqual(newTableQuery, tableQueryData)) {
return;
}
setTableQueryData({ ...newTableQuery });
const {
pageIndex,
searchQuery: newSearchQuery,
sortDirection: newSortDirection,
} = newTableQuery;
let { sortHeader: newSortHeader } = newTableQuery;
pageIndex !== page && setPage(pageIndex);
searchQuery !== newSearchQuery && setSearchQuery(newSearchQuery);
sortDirection !== newSortDirection &&
setSortDirection(
newSortDirection === "asc" || newSortDirection === "desc"
? newSortDirection
: DEFAULT_SORT_DIRECTION
);
if (isPremiumTier && newSortHeader === "vulnerabilities") {
newSortHeader = "epss_probability";
}
sortHeader !== newSortHeader && setSortHeader(newSortHeader);
// Rebuild queryParams to dispatch new browser location to react-router
const newQueryParams: { [key: string]: string | number | undefined } = {};
if (!isEmpty(newSearchQuery)) {
newQueryParams.query = newSearchQuery;
}
newQueryParams.page = pageIndex;
newQueryParams.order_key = newSortHeader || DEFAULT_SORT_HEADER;
newQueryParams.order_direction =
newSortDirection || DEFAULT_SORT_DIRECTION;
newQueryParams.vulnerable = filterVuln ? "true" : undefined;
if (teamIdForApi !== undefined) {
newQueryParams.team_id = teamIdForApi;
}
const locationPath = getNextLocationPath({
pathPrefix: PATHS.MANAGE_SOFTWARE,
routeTemplate,
queryParams: newQueryParams,
});
router.replace(locationPath);
},
[
isRouteOk,
teamIdForApi,
tableQueryData,
page,
searchQuery,
sortDirection,
isPremiumTier,
sortHeader,
DEFAULT_SORT_HEADER,
filterVuln,
routeTemplate,
router,
]
);
const toggleManageAutomationsModal = useCallback(() => {
setShowManageAutomationsModal(!showManageAutomationsModal);
}, [setShowManageAutomationsModal, showManageAutomationsModal]);
const togglePreviewPayloadModal = useCallback(() => {
setShowPreviewPayloadModal(!showPreviewPayloadModal);
}, [setShowPreviewPayloadModal, showPreviewPayloadModal]);
const togglePreviewTicketModal = useCallback(() => {
setShowPreviewTicketModal(!showPreviewTicketModal);
}, [setShowPreviewTicketModal, showPreviewTicketModal]);
const onCreateWebhookSubmit = async (
configSoftwareAutomations: ISoftwareAutomations
) => {
try {
const request = configAPI.update(configSoftwareAutomations);
await request.then(() => {
renderFlash(
"success",
"Successfully updated vulnerability automations."
);
refetchSoftwareConfig();
});
} catch {
renderFlash(
"error",
"Could not update vulnerability automations. Please try again."
);
} finally {
toggleManageAutomationsModal();
}
};
const onTeamChange = useCallback(
(teamId: number) => {
handleTeamChange(teamId);
setPage(0);
},
[handleTeamChange]
);
// NOTE: used to reset page number to 0 when modifying filters
const handleResetPageIndex = () => {
setTableQueryData(
(prevState) =>
({
...prevState,
pageIndex: 0,
} as ITableQueryData)
);
setResetPageIndex(true);
};
// NOTE: used to reset page number to 0 when modifying filters
useEffect(() => {
// TODO: cleanup this effect
setResetPageIndex(false);
}, [queryParams]);
const renderHeaderDescription = () => {
return (
<p>
Search for installed software{" "}
{(isGlobalAdmin || isGlobalMaintainer) &&
(!isPremiumTier || !isAnyTeamSelected) &&
"and manage automations for detected vulnerabilities (CVEs)"}{" "}
on{" "}
<b>
{isPremiumTier && isAnyTeamSelected
? "all hosts assigned to this team"
: "all of your hosts"}
</b>
.
</p>
);
};
const renderSoftwareCount = useCallback(() => {
const count = softwareCount;
const lastUpdatedAt = software?.counts_updated_at;
if (!isSoftwareEnabled || !lastUpdatedAt) {
return null;
}
if (softwareCountError && !isFetchingCount) {
return (
<span className={`${baseClass}__count count-error`}>
Failed to load software count
</span>
);
}
if (count) {
return (
<div
className={`${baseClass}__count ${
isFetchingCount ? "count-loading" : ""
}`}
>
<span>{`${count} software item${count === 1 ? "" : "s"}`}</span>
<LastUpdatedText
lastUpdatedAt={lastUpdatedAt}
whatToRetrieve={"software"}
/>
</div>
);
}
return null;
}, [
isFetchingCount,
software,
softwareCountError,
softwareCount,
isSoftwareEnabled,
]);
const handleVulnFilterDropdownChange = (isFilterVulnerable: string) => {
handleResetPageIndex();
router.replace(
getNextLocationPath({
pathPrefix: PATHS.MANAGE_SOFTWARE,
routeTemplate,
queryParams: {
...queryParams,
vulnerable: isFilterVulnerable,
page: 0, // resets page index
},
})
);
};
const renderVulnFilterDropdown = () => {
return (
<Dropdown
value={filterVuln}
className={`${baseClass}__vuln_dropdown`}
options={VULNERABLE_DROPDOWN_OPTIONS}
searchable={false}
onChange={handleVulnFilterDropdownChange}
tableFilterDropdown
/>
);
};
const renderTableFooter = () => {
return (
<div>
Seeing unexpected software or vulnerabilities?{" "}
<CustomLink
url={GITHUB_NEW_ISSUE_LINK}
text="File an issue on GitHub"
newTab
/>
</div>
);
};
// TODO: Rework after backend is adjusted to differentiate empty search/filter results from
// collecting inventory
const isCollectingInventory =
!searchQuery &&
!filterVuln &&
page === 0 &&
!software?.software &&
software?.counts_updated_at === null;
const isLastPage =
tableQueryData &&
!!softwareCount &&
DEFAULT_PAGE_SIZE * page + (software?.software?.length || 0) >=
softwareCount;
const softwareTableHeaders = useMemo(
() =>
generateSoftwareTableHeaders(
router,
isPremiumTier,
isSandboxMode,
currentTeamId
),
[isPremiumTier, isSandboxMode, router, currentTeamId]
);
const onSelectSingleRow = (row: ISoftwareRowProps) => {
const hostsBySoftwareParams = {
software_id: row.original.id,
team_id: currentTeamId,
};
const path = hostsBySoftwareParams
? `${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams(
hostsBySoftwareParams
)}`
: PATHS.MANAGE_HOSTS;
router.push(path);
};
const searchable =
isSoftwareEnabled &&
(!!software?.software ||
searchQuery !== "" ||
queryParams.vulnerable === "true");
const renderSoftwareTable = () => {
if (
(softwareError && !isFetchingSoftware) ||
(softwareConfigError && !isFetchingSoftwareConfig)
) {
return <TableDataError />;
}
return (
<TableContainer
columnConfigs={softwareTableHeaders}
data={(isSoftwareEnabled && software?.software) || []}
isLoading={
isFetchingCount ||
isFetchingSoftware ||
!globalConfig ||
(!softwareConfig && !softwareConfigError)
}
resultsTitle="software items"
emptyComponent={() => (
<EmptySoftwareTable
isSoftwareDisabled={!isSoftwareEnabled}
isFilterVulnerable={filterVuln}
isSandboxMode={isSandboxMode}
isCollectingSoftware={isCollectingInventory}
isSearching={searchQuery !== ""}
noSandboxHosts={noSandboxHosts}
/>
)}
defaultSortHeader={sortHeader || DEFAULT_SORT_HEADER}
defaultSortDirection={sortDirection || DEFAULT_SORT_DIRECTION}
defaultPageIndex={page || 0}
defaultSearchQuery={searchQuery}
manualSortBy
pageSize={DEFAULT_PAGE_SIZE}
showMarkAllPages={false}
isAllPagesSelected={false}
disableNextPage={isLastPage}
inputPlaceHolder="Search by name or vulnerabilities (CVEs)"
additionalQueries={filterVuln ? "vulnerable" : ""} // additionalQueries serves as a trigger
// for the useDeepEffect hook to fire onQueryChange for events happeing outside of
// the TableContainer
customControl={searchable ? renderVulnFilterDropdown : undefined}
stackControls
renderCount={renderSoftwareCount}
renderFooter={renderTableFooter}
disableMultiRowSelect
{...{ resetPageIndex, searchable, onQueryChange, onSelectSingleRow }}
/>
);
};
return (
<MainContent>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__header-wrap`}>
<div className={`${baseClass}__header`}>
<div className={`${baseClass}__text`}>
<div className={`${baseClass}__title`}>
{isFreeTier && <h1>Software</h1>}
{isPremiumTier &&
((userTeams && userTeams.length > 1) || isOnGlobalTeam) && (
<TeamsDropdown
currentUserTeams={userTeams || []}
selectedTeamId={currentTeamId}
onChange={onTeamChange}
isSandboxMode={isSandboxMode}
/>
)}
{isPremiumTier &&
!isOnGlobalTeam &&
userTeams &&
userTeams.length === 1 && <h1>{userTeams[0].name}</h1>}
</div>
</div>
</div>
{canManageAutomations && !softwareError && isSoftwareConfigLoaded && (
<Button
onClick={toggleManageAutomationsModal}
className={`${baseClass}__manage-automations button`}
variant="brand"
>
<span>Manage automations</span>
</Button>
)}
</div>
<div className={`${baseClass}__description`}>
{renderHeaderDescription()}
</div>
<div className={`${baseClass}__table`}>{renderSoftwareTable()}</div>
{showManageAutomationsModal && (
<ManageAutomationsModal
onCancel={toggleManageAutomationsModal}
softwareVulnerabilityAutomationEnabled={isAnyVulnAutomationEnabled}
softwareVulnerabilityWebhookEnabled={isVulnWebhookEnabled}
currentDestinationUrl={vulnWebhookSettings?.destination_url || ""}
{...{
onCreateWebhookSubmit,
togglePreviewPayloadModal,
togglePreviewTicketModal,
showPreviewPayloadModal,
showPreviewTicketModal,
recentVulnerabilityMaxAge,
}}
/>
)}
</div>
</MainContent>
);
};
export default ManageSoftwarePage;

View File

@ -1,272 +0,0 @@
import React from "react";
import { Column } from "react-table";
import { InjectedRouter } from "react-router";
import ReactTooltip from "react-tooltip";
import { formatSoftwareType, ISoftware } from "interfaces/software";
import { IVulnerability } from "interfaces/vulnerability";
import PATHS from "router/paths";
import {
formatFloatAsPercentage,
getSoftwareBundleTooltipJSX,
} from "utilities/helpers";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
import TextCell from "components/TableContainer/DataTable/TextCell";
import LinkCell from "components/TableContainer/DataTable/LinkCell/LinkCell";
import TooltipWrapper from "components/TooltipWrapper";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip";
import { COLORS } from "styles/var/colors";
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
interface ICellProps {
cell: {
value: number | string | IVulnerability[];
};
row: {
original: ISoftware;
};
}
interface IStringCellProps extends ICellProps {
cell: {
value: string;
};
}
interface INumberCellProps extends ICellProps {
cell: {
value: number;
};
}
interface IVulnCellProps extends ICellProps {
cell: {
value: IVulnerability[];
};
}
interface IHeaderProps {
column: {
title: string;
isSortedDesc: boolean;
};
}
const condenseVulnerabilities = (
vulnerabilities: IVulnerability[]
): string[] => {
const condensed =
(vulnerabilities?.length &&
vulnerabilities
.slice(-3)
.map((v) => v.cve)
.reverse()) ||
[];
return vulnerabilities.length > 3
? condensed.concat(`+${vulnerabilities.length - 3} more`)
: condensed;
};
const getMaxProbability = (vulns: IVulnerability[]) =>
vulns.reduce(
(max, { epss_probability }) => Math.max(max, epss_probability || 0),
0
);
const generateEPSSColumnHeader = (isSandboxMode = false) => {
return {
Header: (headerProps: IHeaderProps): JSX.Element => {
const titleWithToolTip = (
<TooltipWrapper
tipContent={
<>
The probability that this software will be exploited
<br />
in the next 30 days (EPSS probability). This data is
<br />
reported by FIRST.org.
</>
}
>
Probability of exploit
</TooltipWrapper>
);
return (
<>
{isSandboxMode && <PremiumFeatureIconWithTooltip />}
<HeaderCell
value={titleWithToolTip}
isSortedDesc={headerProps.column.isSortedDesc}
/>
</>
);
},
disableSortBy: false,
accessor: "vulnerabilities",
Cell: (cellProps: IVulnCellProps): JSX.Element => {
const vulns = cellProps.cell.value || [];
const maxProbability = (!!vulns.length && getMaxProbability(vulns)) || 0;
const displayValue =
(maxProbability && formatFloatAsPercentage(maxProbability)) ||
DEFAULT_EMPTY_CELL_VALUE;
return (
<span
className={`vulnerabilities ${!vulns.length ? "text-muted" : ""}`}
>
{displayValue}
</span>
);
},
};
};
const generateVulnColumnHeader = () => {
return {
title: "Vulnerabilities",
Header: "Vulnerabilities",
disableSortBy: true,
accessor: "vulnerabilities",
Cell: (cellProps: IVulnCellProps): JSX.Element => {
const vulnerabilities = cellProps.cell.value || [];
const tooltipText = condenseVulnerabilities(vulnerabilities)?.map(
(value) => {
return (
<span key={`vuln_${value}`}>
{value}
<br />
</span>
);
}
);
if (!vulnerabilities?.length) {
return <span className="vulnerabilities text-muted">---</span>;
}
return (
<>
<span
className={`text-cell vulnerabilities ${
vulnerabilities.length > 1 ? "text-muted tooltip" : ""
}`}
data-tip
data-for={`vulnerabilities__${cellProps.row.original.id}`}
data-tip-disable={vulnerabilities.length <= 1}
>
{vulnerabilities.length === 1
? vulnerabilities[0].cve
: `${vulnerabilities.length} vulnerabilities`}
</span>
<ReactTooltip
effect="solid"
backgroundColor={COLORS["tooltip-bg"]}
id={`vulnerabilities__${cellProps.row.original.id}`}
data-html
>
<span className={`vulnerabilities tooltip__tooltip-text`}>
{tooltipText}
</span>
</ReactTooltip>
</>
);
},
};
};
const generateTableHeaders = (
router: InjectedRouter,
isPremiumTier?: boolean,
isSandboxMode?: boolean,
teamId?: number
): Column[] => {
const softwareTableHeaders = [
{
title: "Name",
Header: (cellProps: IHeaderProps): JSX.Element => (
<HeaderCell
value={cellProps.column.title}
isSortedDesc={cellProps.column.isSortedDesc}
/>
),
disableSortBy: false,
accessor: "name",
Cell: (cellProps: IStringCellProps): JSX.Element => {
const { id, name, bundle_identifier: bundle } = cellProps.row.original;
const onClickSoftware = (e: React.MouseEvent) => {
// Allows for button to be clickable in a clickable row
e.stopPropagation();
router?.push(PATHS.SOFTWARE_DETAILS(id.toString()));
};
return (
<LinkCell
path={PATHS.SOFTWARE_DETAILS(id.toString())}
customOnClick={onClickSoftware}
value={name}
tooltipContent={
bundle ? getSoftwareBundleTooltipJSX(bundle) : undefined
}
/>
);
},
sortType: "caseInsensitive",
},
{
title: "Version",
Header: "Version",
disableSortBy: true,
accessor: "version",
Cell: (cellProps: IStringCellProps): JSX.Element => (
<TextCell value={cellProps.cell.value} />
),
},
{
title: "Type",
Header: "Type",
disableSortBy: true,
accessor: "source",
Cell: (cellProps: IStringCellProps): JSX.Element => (
<TextCell formatter={formatSoftwareType} value={cellProps.cell.value} />
),
},
isPremiumTier
? generateEPSSColumnHeader(isSandboxMode)
: generateVulnColumnHeader(),
{
title: "Hosts",
Header: (cellProps: IHeaderProps): JSX.Element => (
<HeaderCell
value={cellProps.column.title}
disableSortBy={false}
isSortedDesc={cellProps.column.isSortedDesc}
/>
),
disableSortBy: false,
accessor: "hosts_count",
Cell: (cellProps: INumberCellProps): JSX.Element => (
<span className="hosts-cell__wrapper">
<span className="hosts-cell__count">
<TextCell value={cellProps.cell.value} />
</span>
<span className="hosts-cell__link">
<ViewAllHostsLink
queryParams={{
software_id: cellProps.row.original.id,
team_id: teamId,
}}
className="software-link"
/>
</span>
</span>
),
},
];
return softwareTableHeaders;
};
export default generateTableHeaders;

View File

@ -1,222 +0,0 @@
.manage-software-page {
&__header-wrap {
display: flex;
align-items: center;
justify-content: space-between;
height: 38px;
.button-wrap {
display: flex;
justify-content: flex-end;
min-width: 266px;
}
}
&__manage-automations {
padding: $pad-small $pad-medium;
}
&__count {
display: flex;
gap: 12px;
}
&__header {
display: flex;
align-items: center;
.form-field {
margin-bottom: 0;
}
}
&__text {
margin-right: $pad-large;
}
&__title {
font-size: $large;
}
&__description {
margin: 0;
margin-bottom: $pad-large;
max-width: 75%;
@media (min-width: $break-md) {
max-width: none;
}
h2 {
text-transform: uppercase;
color: $core-fleet-black;
font-weight: $regular;
font-size: $small;
}
p {
color: $ui-fleet-black-75;
margin: 0;
font-size: $x-small;
font-style: italic;
}
}
&__table {
.table-container {
&__header {
flex-direction: column-reverse; // Search bar on top
@media (min-width: $break-md) {
flex-direction: row;
}
}
&__header-left {
flex-direction: row; // Filter dropdown aligned with count
.controls {
.form-field--dropdown {
margin: 0;
}
.manage-software-page__vuln_dropdown {
.Select-menu-outer {
width: 250px;
max-height: 310px;
.Select-menu {
max-height: none;
}
}
.Select-value {
padding-left: $pad-medium;
padding-right: $pad-medium;
}
.dropdown__custom-value-label {
width: 155px; // Override 105px for longer text options
}
}
}
}
&__search-input,
&__search {
width: 100%; // Search bar across entire table
.input-icon-field__input {
width: 100%;
}
@media (min-width: $break-md) {
width: auto;
.input-icon-field__input {
width: 375px;
}
}
}
&__data-table-block {
.data-table-block {
.data-table__table {
tr {
.software-link {
opacity: 0;
transition: opacity 250ms;
}
&:hover {
.software-link {
opacity: 1;
}
}
}
thead {
.name__header {
width: $col-md;
}
.version__header {
width: 0;
}
.vulnerabilities__header {
display: none;
width: 0;
}
.source__header {
display: none;
width: 0;
}
.hosts_count__header {
width: auto;
border-right: 0;
}
@media (min-width: $break-md) {
.vulnerabilities__header {
display: table-cell;
}
}
@media (min-width: $break-lg) {
.version__header {
width: $col-md;
}
.source__header {
display: table-cell;
}
}
}
tbody {
.name__cell {
max-width: $col-md;
// Tooltip does not get cut off
.children-wrapper {
overflow: initial;
}
}
.version__cell {
width: 0;
}
.source__cell {
display: none;
width: 0;
}
.vulnerabilities__cell {
display: none;
width: 0;
span {
display: inline;
}
.text-muted {
color: $ui-fleet-black-50;
}
}
.hosts_count__cell {
width: auto;
.hosts-cell__wrapper {
display: flex;
align-items: center;
justify-content: space-between;
.hosts-cell__link {
display: flex;
white-space: nowrap;
}
}
}
@media (min-width: $break-sm) {
.name__cell {
max-width: $col-lg;
}
}
@media (min-width: $break-md) {
.vulnerabilities__cell {
display: table-cell;
}
}
@media (min-width: $break-lg) {
.version_cell {
width: $col-md;
}
.source__cell {
display: table-cell;
}
}
}
}
}
}
}
}
}

View File

@ -1 +0,0 @@
export { default } from "./ManageSoftwarePage";

View File

@ -1,145 +0,0 @@
import React, { useContext, useEffect } from "react";
import { useErrorHandler } from "react-error-boundary";
import { useQuery } from "react-query";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import {
formatSoftwareType,
ISoftware,
IGetSoftwareByIdResponse,
} from "interfaces/software";
import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team";
import softwareAPI from "services/entities/software";
import hostCountAPI from "services/entities/host_count";
import {
DEFAULT_EMPTY_CELL_VALUE,
DOCUMENT_TITLE_SUFFIX,
} from "utilities/constants";
import Spinner from "components/Spinner";
import BackLink from "components/BackLink";
import MainContent from "components/MainContent";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import Vulnerabilities from "./components/Vulnerabilities";
const baseClass = "software-details-page";
interface ISoftwareDetailsProps {
params: {
software_id: string;
};
}
const SoftwareDetailsPage = ({
params: { software_id },
}: ISoftwareDetailsProps): JSX.Element => {
const {
isPremiumTier,
isSandboxMode,
currentTeam,
filteredSoftwarePath,
} = useContext(AppContext);
const handlePageError = useErrorHandler();
const { data: software, isFetching: isFetchingSoftware } = useQuery<
IGetSoftwareByIdResponse,
Error,
ISoftware
>(
["softwareById", software_id],
() => softwareAPI.getSoftwareById(software_id),
{
select: (data) => data.software,
onError: (err) => handlePageError(err),
}
);
const { data: hostCount } = useQuery<{ count: number }, Error, number>(
["hostCountBySoftwareId", software_id],
() => hostCountAPI.load({ softwareId: parseInt(software_id, 10) }),
{ select: (data) => data.count }
);
const renderName = (sw: ISoftware) => {
const { name, version } = sw;
if (!name) {
return "--";
}
if (!version) {
return name;
}
return `${name}, ${version}`;
};
// Updates title that shows up on browser tabs
useEffect(() => {
// e.g., Software horizon, 5.2.0 details | Fleet for osquery
document.title = `Software details | ${
software && renderName(software)
} | ${DOCUMENT_TITLE_SUFFIX}`;
}, [location.pathname, software]);
if (!software || isPremiumTier === undefined) {
return <Spinner />;
}
// Function instead of constant eliminates race condition with filteredSoftwarePath
const backToSoftwarePath = () => {
if (filteredSoftwarePath) {
return filteredSoftwarePath;
}
return currentTeam && currentTeam?.id > APP_CONTEXT_NO_TEAM_ID
? `${PATHS.MANAGE_SOFTWARE}?team_id=${currentTeam?.id}`
: PATHS.MANAGE_SOFTWARE;
};
return (
<MainContent className={baseClass}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__header-links`}>
<BackLink text="Back to software" path={backToSoftwarePath()} />
</div>
<div className="header title">
<div className="title__inner">
<div className="name-container">
<h1 className="name">{renderName(software)}</h1>
</div>
</div>
<ViewAllHostsLink
queryParams={{ software_id }}
className={`${baseClass}__hosts-link`}
/>
</div>
<div className="section info">
<div className="info__inner">
<div className="info-flex">
<div className="info-flex__item info-flex__item--title">
<span className="info-flex__header">Type</span>
<span className={`info-flex__data`}>
{formatSoftwareType(software.source)}
</span>
</div>
<div className="info-flex__item info-flex__item--title">
<span className="info-flex__header">Hosts</span>
<span className={`info-flex__data`}>
{hostCount || DEFAULT_EMPTY_CELL_VALUE}
</span>
</div>
</div>
</div>
</div>
<Vulnerabilities
isPremiumTier={isPremiumTier}
isSandboxMode={isSandboxMode}
isLoading={isFetchingSoftware}
software={software}
/>
</div>
</MainContent>
);
};
export default SoftwareDetailsPage;

View File

@ -1,80 +0,0 @@
.software-details-page {
background-color: $ui-off-white;
&__wrapper {
display: grid;
gap: $pad-medium;
}
.header {
flex: 100%;
display: flex;
flex-direction: column;
}
.section {
flex: 100%;
display: flex;
flex-direction: column;
background-color: $core-white;
border-radius: 16px;
border: 1px solid $ui-fleet-black-10;
padding: $pad-xxlarge;
box-shadow: 0px 3px 0px rgba(226, 228, 234, 0.4);
&__header {
font-size: $medium;
font-weight: $bold;
margin: 0 0 $pad-large 0;
}
.info-flex {
display: flex;
flex-wrap: wrap;
.info-flex__item--title {
margin-bottom: 2.5rem;
}
&__item {
font-size: $x-small;
display: flex;
flex-direction: column;
white-space: nowrap;
&--title {
margin-right: $pad-xxlarge;
.info-flex__data {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
&__header {
color: $core-fleet-black;
font-weight: $bold;
}
}
}
.title,
.info {
flex-direction: row;
justify-content: space-between;
margin: 0;
padding-bottom: 0;
}
.name-container {
display: flex;
align-items: center;
}
.name {
font-size: $large;
font-weight: $bold;
}
}

View File

@ -1,105 +0,0 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import createMockSoftware from "__mocks__/softwareMock";
import Vulnerabilities from "./Vulnerabilities";
describe("Vulnerabilities", () => {
const [mockSoftwareWithVuln, mockSoftwareNoVulns] = [
createMockSoftware({
vulnerabilities: [
{
cve: "CVE_333",
details_link: "https://its.really.bad",
cvss_score: 9.5,
epss_probability: 1,
cisa_known_exploit: false,
cve_published: "2023-02-14T20:15:00Z",
},
],
}),
createMockSoftware(),
];
it("renders the empty state when no vulnerabilities are provided", () => {
render(
<Vulnerabilities
isLoading={false}
isPremiumTier
software={mockSoftwareNoVulns}
/>
);
// Empty state
expect(
screen.getByText("No vulnerabilities detected for this software item.")
).toBeInTheDocument();
expect(
screen.getByText("Expecting to see vulnerabilities?")
).toBeInTheDocument();
expect(screen.getByText("File an issue on GitHub")).toBeInTheDocument();
});
it("correctly renders a table when 1 vulnerability is provided, Premium tier", () => {
render(
<Vulnerabilities
isLoading={false}
isPremiumTier
software={mockSoftwareWithVuln}
/>
);
// Rendered table
expect(screen.getByText("Vulnerability")).toBeInTheDocument();
expect(screen.getByText("Probability of exploit")).toBeInTheDocument();
expect(screen.getByText("Severity")).toBeInTheDocument();
expect(screen.getByText("Known exploit")).toBeInTheDocument();
expect(screen.getByText("Published")).toBeInTheDocument();
expect(screen.getByText("CVE_333")).toBeInTheDocument();
expect(screen.getByText("100%")).toBeInTheDocument();
expect(screen.getByText("Critical", { exact: false })).toBeInTheDocument();
expect(screen.getByText("ago", { exact: false })).toBeInTheDocument();
});
it("Only renders the 'Vulnerability' column when 1 vulnerability is provided on Free tier", () => {
render(
<Vulnerabilities
isLoading={false}
isPremiumTier={false}
software={mockSoftwareWithVuln}
/>
);
// Rendered table
expect(screen.getByText("Vulnerability")).toBeInTheDocument();
// No premium-only columns
expect(screen.queryByText("Probability of exploit")).toBeNull();
expect(screen.queryByText("Severity")).toBeNull();
expect(screen.queryByText("Known exploit")).toBeNull();
expect(screen.queryByText("Published")).toBeNull();
// Row data
expect(screen.getByText("CVE_333")).toBeInTheDocument();
expect(screen.queryByText("100%")).toBeNull();
expect(screen.queryByText("Critical", { exact: false })).toBeNull();
expect(screen.queryByText("ago", { exact: false })).toBeNull();
});
// Test for premium icons on column headers in Sandbox mode
it("Renders 4 'Premium feature' tooltips when in premium tier Sandbox mode", () => {
render(
<Vulnerabilities
isLoading={false}
isPremiumTier
isSandboxMode
software={mockSoftwareWithVuln}
/>
);
expect(
screen.getAllByText("This is a Fleet Premium feature.", { exact: false })
).toHaveLength(4);
});
});

View File

@ -1,79 +0,0 @@
import React, { useMemo } from "react";
import { ISoftware } from "interfaces/software";
import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants";
import TableContainer from "components/TableContainer";
import CustomLink from "components/CustomLink";
import EmptyTable from "components/EmptyTable";
import generateVulnTableHeaders from "./VulnTableConfig";
const baseClass = "vulnerabilities";
interface IVulnerabilitiesProps {
isLoading: boolean;
isPremiumTier: boolean;
isSandboxMode?: boolean;
software: ISoftware;
}
const NoVulnsDetected = (): JSX.Element => {
return (
<EmptyTable
header="No vulnerabilities detected for this software item."
info={
<>
Expecting to see vulnerabilities?{" "}
<CustomLink
url={GITHUB_NEW_ISSUE_LINK}
text="File an issue on GitHub"
newTab
/>
</>
}
/>
);
};
const Vulnerabilities = ({
isLoading,
isPremiumTier,
isSandboxMode = false,
software,
}: IVulnerabilitiesProps): JSX.Element => {
const tableHeaders = useMemo(
() => generateVulnTableHeaders(isPremiumTier, isSandboxMode),
[isPremiumTier, isSandboxMode]
);
return (
<div className="section section--vulnerabilities">
<p className="section__header">Vulnerabilities</p>
{software?.vulnerabilities?.length ? (
<>
{software && (
<div className="vuln-table">
<TableContainer
columnConfigs={tableHeaders}
data={software.vulnerabilities}
defaultSortHeader={isPremiumTier ? "epss_probability" : "cve"}
defaultSortDirection={"desc"}
emptyComponent={NoVulnsDetected}
isAllPagesSelected={false}
isLoading={isLoading}
isClientSidePagination
pageSize={20}
resultsTitle={"vulnerabilities"}
showMarkAllPages={false}
/>
</div>
)}
</>
) : (
<NoVulnsDetected />
)}
</div>
);
};
export default Vulnerabilities;

View File

@ -1,60 +0,0 @@
.software-details-page {
&__hosts-link {
display: flex;
align-items: center;
padding-bottom: $pad-small;
height: 20px;
font-size: $x-small;
color: $core-vibrant-blue;
font-weight: $bold;
text-decoration: none;
}
#right-chevron {
width: 16px;
height: 16px;
margin-left: $pad-small;
}
.section--vulnerabilities {
.component__tooltip-wrapper__tip-text {
max-width: $col-md;
white-space: normal;
}
.data-table-block {
.data-table__table {
thead {
.cve__header {
width: $col-md;
}
.epss_probability__header {
width: $col-sm;
}
.cvss_score__header {
width: $col-sm;
}
@media (max-width: $tooltip-break-md) {
.cisa_known_exploit__header {
.component__tooltip-wrapper__tip-text {
max-width: 200px; // Prevents horizontal scrolling off viewport
white-space: normal;
}
}
}
}
tr {
.text-link {
img {
height: 12px;
width: auto;
padding-left: $pad-small;
}
}
}
}
}
}
}

View File

@ -1 +0,0 @@
export { default } from "./Vulnerabilities";

View File

@ -1 +0,0 @@
export { default } from "./SoftwareDetailsPage";

View File

@ -29,7 +29,6 @@ import LabelPage from "pages/LabelPage";
import LoginPage, { LoginPreviewPage } from "pages/LoginPage";
import LogoutPage from "pages/LogoutPage";
import ManageHostsPage from "pages/hosts/ManageHostsPage";
import ManageSoftwarePage from "pages/software/ManageSoftwarePage";
import ManageQueriesPage from "pages/queries/ManageQueriesPage";
import ManagePacksPage from "pages/packs/ManagePacksPage";
import ManagePoliciesPage from "pages/policies/ManagePoliciesPage";
@ -43,7 +42,6 @@ import RegistrationPage from "pages/RegistrationPage";
import ResetPasswordPage from "pages/ResetPasswordPage";
import MDMAppleSSOPage from "pages/MDMAppleSSOPage";
import MDMAppleSSOCallbackPage from "pages/MDMAppleSSOCallbackPage";
import SoftwareDetailsPage from "pages/software/SoftwareDetailsPage";
import ApiOnlyUser from "pages/ApiOnlyUser";
import Fleet403 from "pages/errors/Fleet403";
import Fleet404 from "pages/errors/Fleet404";
@ -60,6 +58,11 @@ import MacOSMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/MacOSMd
import Scripts from "pages/ManageControlsPage/Scripts/Scripts";
import WindowsAutomaticEnrollmentPage from "pages/admin/IntegrationsPage/cards/AutomaticEnrollment/WindowsAutomaticEnrollmentPage";
import HostQueryReport from "pages/hosts/details/HostQueryReport";
import SoftwarePage from "pages/SoftwarePage";
import SoftwareTitles from "pages/SoftwarePage/SoftwareTitles";
import SoftwareVersions from "pages/SoftwarePage/SoftwareVersions";
import SoftwareTitleDetailsPage from "pages/SoftwarePage/SoftwareTitleDetailsPage";
import SoftwareVersionDetailsPage from "pages/SoftwarePage/SoftwareVersionDetailsPage";
import PATHS from "router/paths";
@ -212,9 +215,13 @@ const routes = (
</Route>
<Route path="software">
<IndexRedirect to="manage" />
<Route path="manage" component={ManageSoftwarePage} />
<Route path=":software_id" component={SoftwareDetailsPage} />
<IndexRedirect to="titles" />
<Route component={SoftwarePage}>
<Route path="titles" component={SoftwareTitles} />
<Route path="versions" component={SoftwareVersions} />
</Route>
<Route path="titles/:id" component={SoftwareTitleDetailsPage} />
<Route path="versions/:id" component={SoftwareVersionDetailsPage} />
</Route>
<Route component={AuthGlobalAdminMaintainerRoutes}>
<Route path="packs">

View File

@ -43,6 +43,16 @@ export default {
ADMIN_ORGANIZATION_ADVANCED: `${URL_PREFIX}/settings/organization/advanced`,
ADMIN_ORGANIZATION_FLEET_DESKTOP: `${URL_PREFIX}/settings/organization/fleet-desktop`,
// Software pages
SOFTWARE_TITLES: `${URL_PREFIX}/software/titles`,
SOFTWARE_VERSIONS: `${URL_PREFIX}/software/versions`,
SOFTWARE_TITLE_DETAILS: (id: string): string => {
return `${URL_PREFIX}/software/titles/${id}`;
},
SOFTWARE_VERSION_DETAILS: (id: string): string => {
return `${URL_PREFIX}/software/versions/${id}`;
},
EDIT_PACK: (packId: number): string => {
return `${URL_PREFIX}/packs/${packId}/edit`;
},
@ -109,10 +119,7 @@ export default {
DEVICE_USER_DETAILS_POLICIES: (deviceAuthToken: string): string => {
return `${URL_PREFIX}/device/${deviceAuthToken}/policies`;
},
MANAGE_SOFTWARE: `${URL_PREFIX}/software/manage`,
SOFTWARE_DETAILS: (id: string): string => {
return `${URL_PREFIX}/software/${id}`;
},
TEAM_DETAILS_MEMBERS: (teamId?: number): string => {
if (teamId !== undefined && teamId > 0) {
return `${URL_PREFIX}/settings/teams/members?team_id=${teamId}`;

View File

@ -40,6 +40,8 @@ export interface IHostCountLoadOptions {
policyResponse?: string;
macSettingsStatus?: MacSettingsStatusQueryParam;
softwareId?: number;
softwareTitleId?: number;
softwareVersionId?: number;
lowDiskSpaceHosts?: number;
mdmId?: number;
mdmEnrollmentStatus?: string;
@ -62,6 +64,8 @@ export default {
const globalFilter = options?.globalFilter || "";
const teamId = options?.teamId;
const softwareId = options?.softwareId;
const softwareTitleId = options?.softwareTitleId;
const softwareVersionId = options?.softwareVersionId;
const macSettingsStatus = options?.macSettingsStatus;
const status = options?.status;
const mdmId = options?.mdmId;
@ -91,6 +95,8 @@ export default {
mdmEnrollmentStatus,
munkiIssueId,
softwareId,
softwareTitleId,
softwareVersionId,
lowDiskSpaceHosts,
osName,
osId,

View File

@ -9,7 +9,7 @@ import {
reconcileMutuallyInclusiveHostParams,
} from "utilities/url";
import { SelectedPlatform } from "interfaces/platform";
import { ISoftware } from "interfaces/software";
import { ISoftwareTitle, ISoftware } from "interfaces/software";
import {
DiskEncryptionStatus,
BootstrapPackageStatus,
@ -25,7 +25,8 @@ export interface ISortOption {
export interface ILoadHostsResponse {
hosts: IHost[];
software: ISoftware;
software: ISoftware | undefined;
software_title: ISoftwareTitle | undefined;
munki_issue: IMunkiIssuesAggregate;
mobile_device_management_solution: IMdmSolution;
}
@ -55,6 +56,8 @@ export interface ILoadHostsOptions {
policyResponse?: string;
macSettingsStatus?: MacSettingsStatusQueryParam;
softwareId?: number;
softwareTitleId?: number;
softwareVersionId?: number;
status?: HostStatus;
mdmId?: number;
mdmEnrollmentStatus?: string;
@ -82,6 +85,8 @@ export interface IExportHostsOptions {
policyResponse?: string;
macSettingsStatus?: MacSettingsStatusQueryParam;
softwareId?: number;
softwareTitleId?: number;
softwareVersionId?: number;
status?: HostStatus;
mdmId?: number;
munkiIssueId?: number;
@ -177,6 +182,8 @@ export default {
const policyId = options?.policyId;
const policyResponse = options?.policyResponse || "passing";
const softwareId = options?.softwareId;
const softwareTitleId = options?.softwareTitleId;
const softwareVersionId = options?.softwareVersionId;
const macSettingsStatus = options?.macSettingsStatus;
const status = options?.status;
const mdmId = options?.mdmId;
@ -209,6 +216,8 @@ export default {
mdmEnrollmentStatus,
munkiIssueId,
softwareId,
softwareTitleId,
softwareVersionId,
lowDiskSpaceHosts,
osSettings,
diskEncryptionStatus,
@ -234,6 +243,8 @@ export default {
policyResponse = "passing",
macSettingsStatus,
softwareId,
softwareTitleId,
softwareVersionId,
status,
mdmId,
mdmEnrollmentStatus,
@ -273,6 +284,8 @@ export default {
mdmEnrollmentStatus,
munkiIssueId,
softwareId,
softwareTitleId,
softwareVersionId,
lowDiskSpaceHosts,
osId,
osName,

View File

@ -6,10 +6,12 @@ import {
ISoftwareResponse,
ISoftwareCountResponse,
IGetSoftwareByIdResponse,
ISoftwareVersion,
ISoftwareTitle,
} from "interfaces/software";
import { buildQueryStringFromParams, QueryParams } from "utilities/url";
interface ISoftwareApiParams {
export interface ISoftwareApiParams {
page?: number;
perPage?: number;
orderKey?: string;
@ -19,6 +21,34 @@ interface ISoftwareApiParams {
teamId?: number;
}
export interface ISoftwareTitlesResponse {
counts_updated_at: string | null;
count: number;
software_titles: ISoftwareTitle[];
meta: {
has_next_results: boolean;
has_previous_results: boolean;
};
}
export interface ISoftwareVersionsResponse {
counts_updated_at: string | null;
count: number;
software: ISoftwareVersion[];
meta: {
has_next_results: boolean;
has_previous_results: boolean;
};
}
export interface ISoftwareTitleResponse {
software_title: ISoftwareTitle;
}
export interface ISoftwareVersionResponse {
software: ISoftwareVersion;
}
export interface ISoftwareQueryKey extends ISoftwareApiParams {
scope: "software";
}
@ -103,4 +133,30 @@ export default {
return sendRequest("GET", path);
},
getSoftwareTitles: (params: ISoftwareApiParams) => {
const { SOFTWARE_TITLES } = endpoints;
const snakeCaseParams = convertParamsToSnakeCase(params);
const queryString = buildQueryStringFromParams(snakeCaseParams);
const path = `${SOFTWARE_TITLES}?${queryString}`;
return sendRequest("GET", path);
},
getSoftwareTitle: (id: number) => {
const { SOFTWARE_TITLE } = endpoints;
return sendRequest("GET", SOFTWARE_TITLE(id));
},
getSoftwareVersions: (params: ISoftwareApiParams) => {
const { SOFTWARE_VERSIONS } = endpoints;
const snakeCaseParams = convertParamsToSnakeCase(params);
const queryString = buildQueryStringFromParams(snakeCaseParams);
const path = `${SOFTWARE_VERSIONS}?${queryString}`;
return sendRequest("GET", path);
},
getSoftwareVersion: (id: number) => {
const { SOFTWARE_VERSION } = endpoints;
return sendRequest("GET", SOFTWARE_VERSION(id));
},
};

View File

@ -94,7 +94,15 @@ export default {
return `/${API_VERSION}/fleet/packs/${packId}/scheduled`;
},
SETUP: `/v1/setup`, // not a typo - hasn't been updated yet
// Software endpoints
SOFTWARE: `/${API_VERSION}/fleet/software`,
SOFTWARE_TITLES: `/${API_VERSION}/fleet/software/titles`,
SOFTWARE_TITLE: (id: number) => `/${API_VERSION}/fleet/software/titles/${id}`,
SOFTWARE_VERSIONS: `/${API_VERSION}/fleet/software/versions`,
SOFTWARE_VERSION: (id: number) =>
`/${API_VERSION}/fleet/software/versions/${id}`,
SSO: `/v1/fleet/sso`,
STATUS_LABEL_COUNTS: `/${API_VERSION}/fleet/host_summary`,
STATUS_LIVE_QUERY: `/${API_VERSION}/fleet/status/live_query`,
@ -129,7 +137,7 @@ export default {
USERS_ADMIN: `/${API_VERSION}/fleet/users/admin`,
VERSION: `/${API_VERSION}/fleet/version`,
// SCRIPTS
// Script endpoints
HOST_SCRIPTS: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/scripts`,
SCRIPTS: `/${API_VERSION}/fleet/scripts`,
SCRIPT: (id: number) => `/${API_VERSION}/fleet/scripts/${id}`,

Some files were not shown because too many files have changed in this diff Show More