mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 00:45:19 +00:00
Merge branch 'main' into 14415
This commit is contained in:
commit
df1d279a92
@ -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)
|
||||
|
1
changes/14641-ui-mdm-button-wrap
Normal file
1
changes/14641-ui-mdm-button-wrap
Normal file
@ -0,0 +1 @@
|
||||
- Fixed button text wrapping in UI for Settings > Integrations > MDM.
|
1
changes/15226-hosts-filter-software
Normal file
1
changes/15226-hosts-filter-software
Normal file
@ -0,0 +1 @@
|
||||
- Updated manage hosts UI to filter hosts by `software_version_id` and `software_title_id`.
|
2
changes/issue-15224-15225-implement-new-software-pages
Normal file
2
changes/issue-15224-15225-implement-new-software-pages
Normal file
@ -0,0 +1,2 @@
|
||||
- add new software pages to fleet UI. Includes software titles, software versions, software title
|
||||
details and software version details.
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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:
|
||||
|
||||
|
@ -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 };
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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"] },
|
||||
},
|
||||
|
@ -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";
|
||||
};
|
||||
|
@ -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[];
|
||||
}
|
||||
|
@ -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
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
380
frontend/pages/SoftwarePage/SoftwarePage.tsx
Normal file
380
frontend/pages/SoftwarePage/SoftwarePage.tsx
Normal 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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./SoftwareTitleDetailsTable";
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./SoftwareTitleDetailsPage";
|
303
frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx
Normal file
303
frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx
Normal 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;
|
@ -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;
|
165
frontend/pages/SoftwarePage/SoftwareTitles/_styles.scss
Normal file
165
frontend/pages/SoftwarePage/SoftwareTitles/_styles.scss
Normal 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;
|
||||
}
|
||||
}
|
1
frontend/pages/SoftwarePage/SoftwareTitles/index.ts
Normal file
1
frontend/pages/SoftwarePage/SoftwareTitles/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from "./SoftwareTitles";
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./SoftwareVersionDetailsPage";
|
@ -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;
|
@ -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;
|
166
frontend/pages/SoftwarePage/SoftwareVersions/_styles.scss
Normal file
166
frontend/pages/SoftwarePage/SoftwareVersions/_styles.scss
Normal 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;
|
||||
}
|
||||
}
|
1
frontend/pages/SoftwarePage/SoftwareVersions/index.ts
Normal file
1
frontend/pages/SoftwarePage/SoftwareVersions/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from "./SoftwareVersions";
|
59
frontend/pages/SoftwarePage/_styles.scss
Normal file
59
frontend/pages/SoftwarePage/_styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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";
|
||||
|
@ -0,0 +1 @@
|
||||
export { default } from "./EmptySoftwareTable";
|
@ -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 = ({
|
@ -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";
|
||||
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./SoftwareDetailsSummary";
|
@ -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;
|
@ -0,0 +1,10 @@
|
||||
.version-cell {
|
||||
|
||||
&__version-text-with-tooltip {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&__versions {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./VersionCell";
|
@ -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>• {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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./VulnerabilitiesCell";
|
@ -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;
|
71
frontend/pages/SoftwarePage/components/icons/Chrome.tsx
Normal file
71
frontend/pages/SoftwarePage/components/icons/Chrome.tsx
Normal 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;
|
70
frontend/pages/SoftwarePage/components/icons/Excel.tsx
Normal file
70
frontend/pages/SoftwarePage/components/icons/Excel.tsx
Normal 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;
|
20
frontend/pages/SoftwarePage/components/icons/Extension.tsx
Normal file
20
frontend/pages/SoftwarePage/components/icons/Extension.tsx
Normal 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;
|
292
frontend/pages/SoftwarePage/components/icons/Firefox.tsx
Normal file
292
frontend/pages/SoftwarePage/components/icons/Firefox.tsx
Normal 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;
|
20
frontend/pages/SoftwarePage/components/icons/MacApp.tsx
Normal file
20
frontend/pages/SoftwarePage/components/icons/MacApp.tsx
Normal 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;
|
20
frontend/pages/SoftwarePage/components/icons/Package.tsx
Normal file
20
frontend/pages/SoftwarePage/components/icons/Package.tsx
Normal 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;
|
584
frontend/pages/SoftwarePage/components/icons/Safari.tsx
Normal file
584
frontend/pages/SoftwarePage/components/icons/Safari.tsx
Normal 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;
|
37
frontend/pages/SoftwarePage/components/icons/Slack.tsx
Normal file
37
frontend/pages/SoftwarePage/components/icons/Slack.tsx
Normal 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;
|
@ -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;
|
@ -0,0 +1 @@
|
||||
export { default } from "./SoftwareIcon";
|
84
frontend/pages/SoftwarePage/components/icons/Teams.tsx
Normal file
84
frontend/pages/SoftwarePage/components/icons/Teams.tsx
Normal 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;
|
@ -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;
|
20
frontend/pages/SoftwarePage/components/icons/WindowsApp.tsx
Normal file
20
frontend/pages/SoftwarePage/components/icons/WindowsApp.tsx
Normal 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;
|
94
frontend/pages/SoftwarePage/components/icons/Word.tsx
Normal file
94
frontend/pages/SoftwarePage/components/icons/Word.tsx
Normal 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;
|
30
frontend/pages/SoftwarePage/components/icons/Zoom.tsx
Normal file
30
frontend/pages/SoftwarePage/components/icons/Zoom.tsx
Normal 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;
|
60
frontend/pages/SoftwarePage/components/icons/index.ts
Normal file
60
frontend/pages/SoftwarePage/components/icons/index.ts
Normal 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;
|
0
frontend/pages/SoftwarePage/helpers.ts
Normal file
0
frontend/pages/SoftwarePage/helpers.ts
Normal file
1
frontend/pages/SoftwarePage/index.ts
Normal file
1
frontend/pages/SoftwarePage/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from "./SoftwarePage";
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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";
|
||||
|
@ -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={
|
||||
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { default } from "./ManageSoftwarePage";
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { default } from "./Vulnerabilities";
|
@ -1 +0,0 @@
|
||||
export { default } from "./SoftwareDetailsPage";
|
@ -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">
|
||||
|
@ -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}`;
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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));
|
||||
},
|
||||
};
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user