UI – Add VPP features for iPadOS and iOS (#20755)

## Addresses #20467 – part 2

### Aggregate software:

#### Software titles
<img width="1616" alt="sw-titles-updated"
src="https://github.com/user-attachments/assets/0b9922c7-e36e-4d2f-b204-95c3cdf9b602">

#### Software versions
<img width="1616" alt="Screenshot 2024-07-29 at 6 14 21 PM"
src="https://github.com/user-attachments/assets/5a097700-cd6c-45b1-a21f-9d76a733f0ae">

#### Host software
<img width="1616" alt="Screenshot 2024-07-29 at 6 23 01 PM"
src="https://github.com/user-attachments/assets/84e18695-f47a-4022-bd53-7f5d37ce452a">


### Add software modal (VPP) _screenshots use mocked data - UI is
flexible enough to display cleanly before and after backend is in
place:_
<img width="1339" alt="happy"
src="https://github.com/user-attachments/assets/8900aa93-316c-4a09-8e5a-1a1e45b0c458">

#### No apps:
<img width="1572" alt="Screenshot 2024-07-29 at 6 35 03 PM"
src="https://github.com/user-attachments/assets/466b9b6c-4d3d-49dd-94a9-94e395d89cb7">

#### Not enabled:
<img width="1572" alt="Screenshot 2024-07-29 at 6 37 45 PM"
src="https://github.com/user-attachments/assets/9bcfd480-8741-4d95-ba3b-550dee4dc673">

#### Error:
<img width="1572" alt="Screenshot 2024-07-29 at 6 39 39 PM"
src="https://github.com/user-attachments/assets/e944dd40-676e-4aba-9cd9-49ff319bf402">

### Vuln support – Not supported for now:
_see above screenshots for `list` endpoints_

#### Software title detail
<img width="1616" alt="Screenshot 2024-07-29 at 6 47 29 PM"
src="https://github.com/user-attachments/assets/2e30fd0a-21e4-4d19-bf9b-71a994bfd0e7">

#### Software version and OS detail:
<img width="1616" alt="Screenshot 2024-07-29 at 6 48 28 PM"
src="https://github.com/user-attachments/assets/e8fec769-ba97-4b6b-b10c-9bb4c973c732">
<img width="1616" alt="Screenshot 2024-07-29 at 6 50 25 PM"
src="https://github.com/user-attachments/assets/0ac15727-e0cb-447c-8758-c58b79656d1a">


- [x] Changes file added for user-visible changes in `changes/`,
- [x] Added/updated tests
- [x] Manual QA for all new/changed functionality

---------

Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
This commit is contained in:
jacobshandling
2024-07-30 10:14:25 -07:00
committed by GitHub
co-authored by Jacob Shandling
parent 9bb6ef15ae
commit 19a64941ba
44 changed files with 803 additions and 287 deletions
+1
View File
@@ -0,0 +1 @@
* Add UI features for managing Apple VPP apps for iPadOS and iOS hosts
+2 -1
View File
@@ -28,9 +28,10 @@ export const createMockVppInfo = (
const DEFAULT_MDM_APPLE_VPP_APP_MOCK: IVppApp = {
name: "Test App",
bundle_identifier: "com.test.app",
icon_url: "https://via.placeholder.com/512",
latest_version: "1.0",
app_store_id: 1,
app_store_id: "1",
added: false,
platform: "darwin",
};
@@ -3,6 +3,7 @@ import classnames from "classnames";
import CustomLink from "components/CustomLink";
import Icon from "components/Icon";
import Graphic from "components/Graphic";
const baseClass = "data-error";
@@ -14,6 +15,7 @@ interface IDataErrorProps {
children?: React.ReactNode;
card?: boolean;
className?: string;
useNew?: boolean;
}
const DEFAULT_DESCRIPTION = "Refresh the page or log in again.";
@@ -24,8 +26,36 @@ const DataError = ({
children,
card,
className,
useNew = false,
}: IDataErrorProps): JSX.Element => {
const classes = classnames(baseClass, className);
if (useNew) {
return (
<div className={classes}>
<div className={`${baseClass}__${card ? "card" : "inner-new"}`}>
<Graphic name="data-error" />
<div className={`${baseClass}__header`}>
Something&apos;s gone wrong.
</div>
{children || (
<>
<div className={`${baseClass}__data`}>Refresh to try again.</div>
{!excludeIssueLink && (
<div className={`${baseClass}__data`}>
If this keeps happening please&nbsp;
<CustomLink
url="https://github.com/fleetdm/fleet/issues/new/choose"
text="file an issue"
newTab
/>
</div>
)}
</>
)}
</div>
</div>
);
}
return (
<div className={classes}>
@@ -32,4 +32,21 @@
margin-top: 10px;
}
}
// // // // // // // // // // // //
// new version
&__inner-new {
display: flex;
flex-direction: column;
gap: 8px;
text-align: center;
.graphic {
margin-bottom: 8px;
}
color: $core-fleet-black;
font-size: $x-small;
}
&__header {
font-weight: $bold;
}
}
@@ -4,9 +4,10 @@ import React from "react";
import { Link } from "react-router";
import classnames from "classnames";
import TooltipWrapper from "components/TooltipWrapper";
import TextCell from "../TextCell";
interface ILinkCellProps {
value: string | JSX.Element;
value?: string | JSX.Element;
path: string;
className?: string;
customOnClick?: (e: React.MouseEvent) => void;
@@ -25,6 +26,10 @@ const LinkCell = ({
title,
tooltipContent,
}: ILinkCellProps): JSX.Element => {
// text cell with no value renders desired empty cell
if (!value) {
return <TextCell />;
}
const cellClasses = classnames(baseClass, className);
const onClick = (e: React.MouseEvent): void => {
@@ -51,14 +51,14 @@
&__option {
display: flex;
flex-direction: column;
gap: $pad-small;
width: 100%;
}
&__help-text {
margin-top: $pad-xsmall;
font-size: $xx-small;
white-space: normal;
color: $core-fleet-blue;
color: $ui-fleet-black-50;
font-style: italic;
}
}
File diff suppressed because one or more lines are too long
+2
View File
@@ -18,6 +18,7 @@ import EmptyTeams from "./EmptyTeams";
import EmptyPacks from "./EmptyPacks";
import EmptySchedule from "./EmptySchedule";
import CollectingResults from "./CollectingResults";
import DataError from "./DataError";
export const GRAPHIC_MAP = {
// Empty state graphics
@@ -43,6 +44,7 @@ export const GRAPHIC_MAP = {
"file-vpp": FileVpp,
// Other graphics
"collecting-results": CollectingResults,
"data-error": DataError,
};
export type GraphicNames = keyof typeof GRAPHIC_MAP;
+2
View File
@@ -1,3 +1,4 @@
import { Platform } from "./platform";
import { IPolicy } from "./policy";
import { IQuery } from "./query";
import { ISchedulableQueryStats } from "./schedulable_query";
@@ -162,6 +163,7 @@ export interface IActivityDetails {
stats?: ISchedulableQueryStats;
software_title?: string;
software_package?: string;
platform?: Platform; // software platform
status?: string;
install_uuid?: string;
self_service?: boolean;
+21 -9
View File
@@ -1,16 +1,25 @@
export type AppleDisplayPlatform = "macOS" | "iOS" | "iPadOS";
export type DisplayPlatform =
| AppleDisplayPlatform
| "Windows"
| "Linux"
| "ChromeOS";
export const APPLE_PLATFORM_DISPLAY_NAMES = {
darwin: "macOS",
ios: "iOS",
ipados: "iPadOS",
} as const;
export type ApplePlatform = keyof typeof APPLE_PLATFORM_DISPLAY_NAMES;
export type AppleDisplayPlatform = typeof APPLE_PLATFORM_DISPLAY_NAMES[keyof typeof APPLE_PLATFORM_DISPLAY_NAMES];
export const PLATFORM_DISPLAY_NAMES = {
windows: "Windows",
linux: "Linux",
chrome: "ChromeOS",
...APPLE_PLATFORM_DISPLAY_NAMES,
} as const;
export type Platform = keyof typeof PLATFORM_DISPLAY_NAMES;
export type DisplayPlatform = typeof PLATFORM_DISPLAY_NAMES[keyof typeof PLATFORM_DISPLAY_NAMES];
export type QueryableDisplayPlatform = Exclude<
DisplayPlatform,
"iOS" | "iPadOS"
>;
export type ApplePlatform = "darwin" | "ios" | "ipados";
export type Platform = ApplePlatform | "windows" | "linux" | "chrome";
export type QueryablePlatform = Exclude<Platform, "ios" | "ipados">;
export const SUPPORTED_PLATFORMS: QueryablePlatform[] = [
@@ -20,6 +29,9 @@ export const SUPPORTED_PLATFORMS: QueryablePlatform[] = [
"chrome",
];
// TODO - add "iOS" and "iPadOS" once we support them
export const VULN_SUPPORTED_PLATFORMS: Platform[] = ["darwin", "windows"];
export type SelectedPlatform = QueryablePlatform | "all";
export type SelectedPlatformString =
+3 -3
View File
@@ -34,7 +34,7 @@ export interface ISoftware {
name: string; // e.g., "Figma.app"
version: string; // e.g., "2.1.11"
bundle_identifier?: string | null; // e.g., "com.figma.Desktop"
source: string; // "apps" | "ipados" | "ios" | "programs" | ?
source: string; // "apps" | "ipados_apps" | "ios_apps" | "programs" | ?
generated_cpe: string;
vulnerabilities: ISoftwareVulnerability[] | null;
hosts_count?: number;
@@ -148,8 +148,8 @@ export const SOURCE_TYPE_CONVERSION: Record<string, string> = {
atom_packages: "Package (Atom)", // Atom packages were removed from software inventory. Mapping is maintained for backwards compatibility. (2023-12-04)
python_packages: "Package (Python)",
apps: "Application (macOS)",
ios: "Application (iOS)",
ipados: "Application (iPadOS)",
ios_apps: "Application (iOS)",
ipados_apps: "Application (iPadOS)",
chrome_extensions: "Browser plugin", // chrome_extensions can include any chrome-based browser (e.g., edge), so we rely instead on the `browser` field computed by Fleet server and fallback to this value if it is not present.
firefox_addons: "Browser plugin (Firefox)",
safari_extensions: "Browser plugin (Safari)",
@@ -4,7 +4,10 @@ import { formatDistanceToNowStrict } from "date-fns";
import { ActivityType, IActivity, IActivityDetails } from "interfaces/activity";
import { getInstallStatusPredicate } from "interfaces/software";
import { AppleDisplayPlatform } from "interfaces/platform";
import {
AppleDisplayPlatform,
PLATFORM_DISPLAY_NAMES,
} from "interfaces/platform";
import {
addGravatarUrlToResource,
@@ -882,10 +885,13 @@ const TAGGED_TEMPLATES = {
);
},
addedAppStoreApp: (activity: IActivity) => {
const { software_title: swTitle, platform: swPlatform } =
activity.details || {};
return (
<>
{" "}
added <b>{activity.details?.software_title}</b> to{" "}
added <b>{swTitle}</b>{" "}
{swPlatform ? `(${PLATFORM_DISPLAY_NAMES[swPlatform]}) ` : ""}to{" "}
{activity.details?.team_name ? (
<>
{" "}
@@ -898,10 +904,13 @@ const TAGGED_TEMPLATES = {
);
},
deletedAppStoreApp: (activity: IActivity) => {
const { software_title: swTitle, platform: swPlatform } =
activity.details || {};
return (
<>
{" "}
deleted <b>{activity.details?.software_title}</b> from{" "}
deleted <b>{swTitle}</b>{" "}
{swPlatform ? `(${PLATFORM_DISPLAY_NAMES[swPlatform]}) ` : ""}from{" "}
{activity.details?.team_name ? (
<>
{" "}
@@ -108,7 +108,7 @@ const generateDefaultTableHeaders = (
Cell: (cellProps: IVulnCellProps) => {
const platform = cellProps.row.original.platform;
if (platform !== "darwin" && platform !== "windows") {
return <TextCell value="Not supported" grey italic />;
return <TextCell value="Not supported" grey />;
}
return <VulnerabilitiesCell vulnerabilities={cellProps.cell.value} />;
},
+3 -1
View File
@@ -4,6 +4,8 @@ import React, { useEffect } from "react";
import { InjectedRouter } from "react-router";
import PATHS from "router/paths";
import { CONTACT_FLEET_LINK } from "utilities/constants";
import Button from "components/buttons/Button/Button";
// @ts-ignore
import StackedWhiteBoxes from "components/StackedWhiteBoxes";
@@ -48,7 +50,7 @@ const NoAccessPage = ({ router, orgContactUrl }: INoAccessPageProps) => {
<p>
To get access,{" "}
<CustomLink
url={orgContactUrl || "https://fleetdm.com/contact"}
url={orgContactUrl || CONTACT_FLEET_LINK}
text="contact your administrator"
/>
.
@@ -11,64 +11,34 @@ import useTeamIdParam from "hooks/useTeamIdParam";
import { AppContext } from "context/app";
import { ignoreAxiosError } from "interfaces/errors";
import {
isLinuxLike,
Platform,
VULN_SUPPORTED_PLATFORMS,
} from "interfaces/platform";
import osVersionsAPI, {
IOSVersionResponse,
IGetOsVersionQueryKey,
} from "services/entities/operating_systems";
import { IOperatingSystemVersion } from "interfaces/operating_system";
import { isLinuxLike } from "interfaces/platform";
import { DEFAULT_USE_QUERY_OPTIONS, SUPPORT_LINK } from "utilities/constants";
import {
DEFAULT_USE_QUERY_OPTIONS,
PLATFORM_DISPLAY_NAMES,
} from "utilities/constants";
import Spinner from "components/Spinner";
import MainContent from "components/MainContent";
import EmptyTable from "components/EmptyTable";
import CustomLink from "components/CustomLink";
import TeamsHeader from "components/TeamsHeader";
import Card from "components/Card";
import SoftwareDetailsSummary from "../components/SoftwareDetailsSummary";
import SoftwareVulnerabilitiesTable from "../components/SoftwareVulnerabilitiesTable";
import DetailsNoHosts from "../components/DetailsNoHosts";
import { VulnsNotSupported } from "../components/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable";
const baseClass = "software-os-details-page";
interface INotSupportedVulnProps {
platform: string;
}
const platformDisplayName = (platform: string) => {
if (isLinuxLike(platform)) {
return "Linux hosts";
}
switch (platform) {
case "chrome":
return "Chromebooks";
case "ios":
return "iPhones";
case "ipados":
return "iPads";
default:
return "this operating system";
}
};
const NotSupportedVuln = ({ platform }: INotSupportedVulnProps) => {
return (
<EmptyTable
header="Vulnerabilities are not supported for this type of host"
info={
<>
Interested in vulnerability management for{" "}
{platformDisplayName(platform)}?{" "}
<CustomLink url={SUPPORT_LINK} text="Let us know" newTab />
</>
}
/>
);
};
interface ISoftwareOSDetailsRouteParams {
id: string;
team_id?: string;
@@ -145,10 +115,13 @@ const SoftwareOSDetailsPage = ({
}
if (
osVersionDetails.platform !== "darwin" &&
osVersionDetails.platform !== "windows"
// TODO - detangle platform typing here
!VULN_SUPPORTED_PLATFORMS.includes(osVersionDetails.platform as Platform)
) {
return <NotSupportedVuln platform={osVersionDetails.platform} />;
const supportInterestText = isLinuxLike(osVersionDetails.platform)
? "Linux"
: PLATFORM_DISPLAY_NAMES[osVersionDetails.platform];
return <VulnsNotSupported supportInterestText={supportInterestText} />;
}
return (
@@ -202,6 +202,9 @@ const SoftwareTitleDetailsPage = ({
data={softwareTitle.versions ?? []}
isLoading={isSoftwareTitleLoading}
teamIdForApi={teamIdForApi}
isIPadOSOrIOSApp={["ios_apps", "ipados_apps"].includes(
softwareTitle.source
)}
/>
</Card>
</>
@@ -44,6 +44,7 @@ interface ISoftwareTitleDetailsTableProps {
data: ISoftwareTitleVersion[];
isLoading: boolean;
teamIdForApi?: number;
isIPadOSOrIOSApp: boolean;
}
interface IRowProps extends Row {
@@ -57,6 +58,7 @@ const SoftwareTitleDetailsTable = ({
data,
isLoading,
teamIdForApi,
isIPadOSOrIOSApp,
}: ISoftwareTitleDetailsTableProps) => {
const handleRowSelect = (row: IRowProps) => {
const hostsBySoftwareParams = {
@@ -74,8 +76,12 @@ const SoftwareTitleDetailsTable = ({
const softwareTableHeaders = useMemo(
() =>
generateSoftwareTitleDetailsTableConfig({ router, teamId: teamIdForApi }),
[router, teamIdForApi]
generateSoftwareTitleDetailsTableConfig({
router,
teamId: teamIdForApi,
isIPadOSOrIOSApp,
}),
[router, teamIdForApi, isIPadOSOrIOSApp]
);
const renderVersionsCount = () => (
@@ -17,6 +17,7 @@ import VulnerabilitiesCell from "../../components/VulnerabilitiesCell";
interface ISoftwareTitleDetailsTableConfigProps {
router: InjectedRouter;
teamId?: number;
isIPadOSOrIOSApp: boolean;
}
interface ICellProps {
cell: {
@@ -48,6 +49,7 @@ interface IVulnCellProps extends ICellProps {
const generateSoftwareTitleDetailsTableConfig = ({
router,
teamId,
isIPadOSOrIOSApp,
}: ISoftwareTitleDetailsTableConfigProps) => {
const tableHeaders = [
{
@@ -90,10 +92,13 @@ const generateSoftwareTitleDetailsTableConfig = ({
// 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} />
Cell: (cellProps: IVulnCellProps): JSX.Element => {
if (isIPadOSOrIOSApp) {
return <TextCell value="Not supported" grey />;
}
return <VulnerabilitiesCell vulnerabilities={cellProps.cell.value} />;
// TODO: tooltip
),
},
},
{
title: "Hosts",
@@ -3,6 +3,7 @@ import {
ISoftwareTitleDetails,
isSoftwarePackage,
} from "interfaces/software";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
/**
* Generates the data needed to render the package card.
@@ -18,9 +19,10 @@ export const getPackageCardInfo = (softwareTitle: ISoftwareTitleDetails) => {
return {
softwarePackage: isSoftwarePackage(packageData) ? packageData : undefined,
name: softwareTitle.name,
version: isSoftwarePackage(packageData)
? packageData.version
: packageData.latest_version,
version:
(isSoftwarePackage(packageData)
? packageData.version
: packageData.latest_version) || DEFAULT_EMPTY_CELL_VALUE,
uploadedAt: isSoftwarePackage(packageData) ? packageData.uploaded_at : "",
status: packageData.status,
isSelfService: isSoftwarePackage(packageData)
@@ -2,13 +2,7 @@ import React from "react";
import { CellProps, Column } from "react-table";
import { InjectedRouter } from "react-router";
import {
IAppStoreApp,
ISoftware,
ISoftwarePackage,
ISoftwareTitle,
formatSoftwareType,
} from "interfaces/software";
import { ISoftwareTitle, formatSoftwareType } from "interfaces/software";
import PATHS from "router/paths";
import { buildQueryStringFromParams } from "utilities/url";
@@ -123,14 +117,6 @@ const generateTableHeaders = (
},
sortType: "caseInsensitive",
},
{
Header: "Type",
disableSortBy: true,
accessor: "source",
Cell: (cellProps: ITableStringCellProps) => (
<TextCell value={formatSoftwareType(cellProps.row.original)} />
),
},
{
Header: "Version",
disableSortBy: true,
@@ -139,6 +125,14 @@ const generateTableHeaders = (
<VersionCell versions={cellProps.cell.value} />
),
},
{
Header: "Type",
disableSortBy: true,
accessor: "source",
Cell: (cellProps: ITableStringCellProps) => (
<TextCell value={formatSoftwareType(cellProps.row.original)} />
),
},
// 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
@@ -149,6 +143,11 @@ const generateTableHeaders = (
Header: "Vulnerabilities",
disableSortBy: true,
Cell: (cellProps: IVulnerabilitiesCellProps) => {
if (
["ios_apps", "ipados_apps"].includes(cellProps.row.original.source)
) {
return <TextCell value="Not supported" grey />;
}
const vulnerabilities = getVulnerabilities(
cellProps.row.original.versions ?? []
);
@@ -63,14 +63,6 @@ const generateTableHeaders = (
},
sortType: "caseInsensitive",
},
{
Header: "Type",
disableSortBy: true,
accessor: "source",
Cell: (cellProps: ITableStringCellProps) => (
<TextCell value={formatSoftwareType(cellProps.row.original)} />
),
},
{
Header: "Version",
disableSortBy: true,
@@ -79,13 +71,26 @@ const generateTableHeaders = (
<TextCell value={cellProps.cell.value} />
),
},
{
Header: "Type",
disableSortBy: true,
accessor: "source",
Cell: (cellProps: ITableStringCellProps) => (
<TextCell value={formatSoftwareType(cellProps.row.original)} />
),
},
{
Header: "Vulnerabilities",
disableSortBy: true,
accessor: "vulnerabilities",
Cell: (cellProps: IVulnerabilitiesCellProps) => (
<VulnerabilitiesCell vulnerabilities={cellProps.cell.value} />
),
Cell: (cellProps: IVulnerabilitiesCellProps) => {
if (
["ipados_apps", "ios_apps"].includes(cellProps.row.original.source)
) {
return <TextCell value="Not supported" grey />;
}
return <VulnerabilitiesCell vulnerabilities={cellProps.cell.value} />;
},
},
{
Header: (cellProps: ITableHeaderProps) => (
@@ -31,6 +31,7 @@ import Card from "components/Card";
import SoftwareDetailsSummary from "../components/SoftwareDetailsSummary";
import SoftwareVulnerabilitiesTable from "../components/SoftwareVulnerabilitiesTable";
import DetailsNoHosts from "../components/DetailsNoHosts";
import { VulnsNotSupported } from "../components/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable";
const baseClass = "software-version-details-page";
@@ -112,6 +113,23 @@ const SoftwareVersionDetailsPage = ({
[handleTeamChange]
);
const renderVulnTable = (swVersion: ISoftwareVersion) => {
if (["ios_apps", "ipados_apps"].includes(swVersion.source)) {
const supportInterestText =
swVersion.source === "ios_apps" ? "iOS" : "iPadOS";
return <VulnsNotSupported supportInterestText={supportInterestText} />;
}
return (
<SoftwareVulnerabilitiesTable
data={swVersion.vulnerabilities ?? []}
itemName="software item"
isLoading={isSoftwareVersionLoading}
router={router}
teamIdForApi={teamIdForApi}
/>
);
};
const renderContent = () => {
if (isSoftwareVersionLoading) {
return <Spinner />;
@@ -157,13 +175,7 @@ const SoftwareVersionDetailsPage = ({
className={`${baseClass}__vulnerabilities-section`}
>
<h2 className="section__header">Vulnerabilities</h2>
<SoftwareVulnerabilitiesTable
data={softwareVersion.vulnerabilities ?? []}
itemName="software item"
isLoading={isSoftwareVersionLoading}
router={router}
teamIdForApi={teamIdForApi}
/>
{renderVulnTable(softwareVersion)}
</Card>
</>
)}
@@ -10,6 +10,8 @@ import mdmAppleAPI, {
} from "services/entities/mdm_apple";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import { PLATFORM_DISPLAY_NAMES } from "interfaces/platform";
import Card from "components/Card";
import CustomLink from "components/CustomLink";
import Spinner from "components/Spinner";
@@ -29,7 +31,7 @@ const EnableVppCard = () => {
<Card borderRadiusSize="medium">
<div className={`${baseClass}__enable-vpp`}>
<p className={`${baseClass}__enable-vpp-title`}>
<b>Volume Purchasing Program (VPP) isnt enabled.</b>
<b>Volume Purchasing Program (VPP) isn&apos;t enabled</b>
</p>
<p className={`${baseClass}__enable-vpp-description`}>
To add App Store apps, first enable VPP.
@@ -66,6 +68,11 @@ const VppAppListItem = ({ app, selected, onSelect }: IVppAppListItemProps) => {
name="vppApp"
onChange={() => onSelect(app)}
/>
{app.platform && (
<div className="app-platform">
{PLATFORM_DISPLAY_NAMES[app.platform]}
</div>
)}
</li>
);
};
@@ -76,40 +83,20 @@ interface IVppAppListProps {
onSelect: (app: IVppApp) => void;
}
const VppAppList = ({ apps, selectedApp, onSelect }: IVppAppListProps) => {
const renderContent = () => {
if (apps.length === 0) {
return (
<div className={`${baseClass}__no-software`}>
<p className={`${baseClass}__no-software-title`}>
You don&apos;t have any App Store apps
</p>
<p className={`${baseClass}__no-software-description`}>
You must purchase apps in ABM. App Store apps that are already added
to this team are not listed.
</p>
</div>
);
}
return (
<ul className={`${baseClass}__list`}>
{apps.map((app) => (
<VppAppListItem
key={app.app_store_id}
app={app}
selected={selectedApp?.app_store_id === app.app_store_id}
onSelect={onSelect}
/>
))}
</ul>
);
};
return (
<div className={`${baseClass}__list-container`}>{renderContent()}</div>
);
};
const VppAppList = ({ apps, selectedApp, onSelect }: IVppAppListProps) => (
<div className={`${baseClass}__list-container`}>
<ul className={`${baseClass}__list`}>
{apps.map((app) => (
<VppAppListItem
key={app.app_store_id}
app={app}
selected={selectedApp?.app_store_id === app.app_store_id}
onSelect={onSelect}
/>
))}
</ul>
</div>
);
interface IAppStoreVppProps {
teamId: number;
@@ -193,20 +180,41 @@ const AppStoreVpp = ({ teamId, router, onExit }: IAppStoreVppProps) => {
return <DataError className={`${baseClass}__error`} />;
}
return vppApps ? (
<VppAppList
apps={vppApps}
selectedApp={selectedApp}
onSelect={onSelectApp}
/>
) : null;
if (vppApps) {
if (vppApps.length === 0) {
return (
<div className={`${baseClass}__no-software`}>
<p className={`${baseClass}__no-software-title`}>
You don&apos;t have any App Store apps
</p>
<p className={`${baseClass}__no-software-description`}>
Add apps in{" "}
<CustomLink url="https://business.apple.com" text="ABM" newTab />{" "}
Apps that are already added to this team are not listed.
</p>
</div>
);
}
return (
<>
<VppAppList
apps={vppApps}
selectedApp={selectedApp}
onSelect={onSelectApp}
/>
<div className={`${baseClass}__help-text`}>
These apps were added in Apple Business Manager (ABM). To add more
apps, head to{" "}
<CustomLink url="https://business.apple.com" text="ABM" newTab />
</div>
</>
);
}
return null;
};
return (
<div className={baseClass}>
<p className={`${baseClass}__description`}>
Apple App Store apps purchased via Apple Business Manager.
</p>
{renderContent()}
<div className="modal-cta-wrap">
<Button
@@ -1,13 +1,10 @@
.app-store-vpp {
margin-top: $pad-large;
&__description {
margin: $pad-medium 0;
}
&__list-container {
border: 1px solid $ui-fleet-black-10;
border-radius: $border-radius-medium;
margin-bottom: $pad-medium;
}
&__list {
@@ -17,9 +14,16 @@
}
&__list-item {
display: flex;
gap: $pad-medium;
align-items: center;
padding: $pad-small $pad-medium;
border-bottom: 1px solid $ui-fleet-black-10;
.app-platform {
color: $ui-fleet-black-50;
}
&:last-child {
border-bottom: none;
}
@@ -32,8 +36,11 @@
}
&__no-software {
display: flex;
flex-direction: column;
gap: $pad-small;
align-items: center;
padding: $pad-xxlarge 48px;
text-align: center;
font-size: $x-small;
}
@@ -43,7 +50,7 @@
}
&__no-software-description {
margin-top: $pad-small;
margin: 0;
color: $ui-fleet-black-75;
}
@@ -63,4 +70,11 @@
margin: 0;
}
}
&__help-text {
@include help-text;
.custom-link {
font-size: $xx-small;
}
}
}
@@ -17,7 +17,6 @@ export interface IEmptySoftwareTableProps {
isNotDetectingSoftware?: boolean;
/** isCollectingSoftware is only used on the Dashboard page with a TODO to revisit */
isCollectingSoftware?: boolean;
isFilterVulnerable?: boolean;
}
const generateTypeText = (
@@ -39,7 +38,6 @@ const EmptySoftwareTable = ({
isSoftwareDisabled,
isNotDetectingSoftware,
isCollectingSoftware,
isFilterVulnerable,
}: IEmptySoftwareTableProps): JSX.Element => {
const softwareTypeText = generateTypeText(tableName, softwareFilter);
@@ -1,7 +1,7 @@
/**
software/versions/:id > Vulnerabilities table
software/os/:id > Vulnerabilities table
*/
software/versions/:id > Vulnerabilities table
software/os/:id > Vulnerabilities table
*/
import React, { useContext, useMemo } from "react";
import classnames from "classnames";
@@ -11,7 +11,8 @@ import PATHS from "router/paths";
import { AppContext } from "context/app";
import { ISoftwareVulnerability } from "interfaces/software";
import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants";
import { CONTACT_FLEET_LINK, GITHUB_NEW_ISSUE_LINK } from "utilities/constants";
import { DisplayPlatform } from "interfaces/platform";
import { buildQueryStringFromParams } from "utilities/url";
import TableContainer from "components/TableContainer";
import TableCount from "components/TableContainer/TableCount";
@@ -26,6 +27,10 @@ interface INoVulnsDetectedProps {
itemName: string;
}
interface IVulnsNotSupportedProps {
supportInterestText?: DisplayPlatform;
}
const NoVulnsDetected = ({ itemName }: INoVulnsDetectedProps): JSX.Element => {
return (
<EmptyTable
@@ -44,6 +49,21 @@ const NoVulnsDetected = ({ itemName }: INoVulnsDetectedProps): JSX.Element => {
);
};
export const VulnsNotSupported = ({
supportInterestText,
}: IVulnsNotSupportedProps) => (
<EmptyTable
header="Vulnerabilities are not supported for this type of host"
info={
<>
Interested in vulnerabilities in{" "}
{supportInterestText ?? "this platform"}?{" "}
<CustomLink url={CONTACT_FLEET_LINK} text="Let us know" newTab />
</>
}
/>
);
interface ISoftwareVulnerabilitiesTableProps {
data: ISoftwareVulnerability[];
/** Name displayed on the empty state */
@@ -2,7 +2,7 @@ import React from "react";
import type { SVGProps } from "react";
const MacApp = (props: SVGProps<SVGSVGElement>) => (
const AppleApp = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" {...props}>
<path fill="#515774" d="M0 0h32v32H0z" />
<path
@@ -13,4 +13,4 @@ const MacApp = (props: SVGProps<SVGSVGElement>) => (
/>
</svg>
);
export default MacApp;
export default AppleApp;
@@ -6,7 +6,7 @@ import ChromeApp from "./ChromeApp";
import Excel from "./Excel";
import Extension from "./Extension";
import Firefox from "./Firefox";
import MacApp from "./MacApp";
import AppleApp from "./AppleApp";
import MacOS from "./MacOS";
import Package from "./Package";
import Safari from "./Safari";
@@ -65,7 +65,9 @@ const SOFTWARE_SOURCE_TO_ICON_MAP = {
atom_packages: Package,
python_packages: Package,
homebrew_packages: Package,
apps: MacApp,
apps: AppleApp,
ios_apps: AppleApp,
ipados_apps: AppleApp,
programs: WindowsApp,
chrome_extensions: Extension,
safari_extensions: Extension,
@@ -12,7 +12,6 @@
p {
margin: 0;
max-width: 520px;
span {
display: flex;
@@ -1,6 +1,9 @@
import React, { useState, useEffect, useContext } from "react";
import { AppContext } from "context/app";
import { CONTACT_FLEET_LINK } from "utilities/constants";
import Button from "components/buttons/Button";
import Checkbox from "components/forms/fields/Checkbox";
// @ts-ignore
@@ -188,11 +191,7 @@ const Smtp = ({
<>
To configure SMTP,{" "}
<CustomLink
url={
isPremiumTier
? "https://fleetdm.com/contact"
: "https://fleetdm.com/slack"
}
url={isPremiumTier ? CONTACT_FLEET_LINK : "https://fleetdm.com/slack"}
text="get help"
newTab
/>
@@ -428,7 +428,6 @@ const DeviceUserPage = ({
queryParams={parseHostSoftwareQueryParams(location.query)}
isMyDevicePage
hostTeamId={host.team_id || 0}
hostPlatform={host?.platform || ""}
isSoftwareEnabled={isSoftwareEnabled}
/>
</TabPanel>
@@ -854,31 +854,29 @@ const HostDetailsPage = ({
munki={macadmins?.munki}
mdm={mdm}
/>
{!isIosOrIpadosHost && (
<ActivityCard
activeTab={activeActivityTab}
activities={
activeActivityTab === "past"
? pastActivities
: upcomingActivities
}
isLoading={
activeActivityTab === "past"
? pastActivitiesIsFetching
: upcomingActivitiesIsFetching
}
isError={
activeActivityTab === "past"
? pastActivitiesIsError
: upcomingActivitiesIsError
}
upcomingCount={upcomingActivities?.count || 0}
onChangeTab={onChangeActivityTab}
onNextPage={() => setActivityPage(activityPage + 1)}
onPreviousPage={() => setActivityPage(activityPage - 1)}
onShowDetails={onShowActivityDetails}
/>
)}
<ActivityCard
activeTab={activeActivityTab}
activities={
activeActivityTab === "past"
? pastActivities
: upcomingActivities
}
isLoading={
activeActivityTab === "past"
? pastActivitiesIsFetching
: upcomingActivitiesIsFetching
}
isError={
activeActivityTab === "past"
? pastActivitiesIsError
: upcomingActivitiesIsError
}
upcomingCount={upcomingActivities?.count || 0}
onChangeTab={onChangeActivityTab}
onNextPage={() => setActivityPage(activityPage + 1)}
onPreviousPage={() => setActivityPage(activityPage - 1)}
onShowDetails={onShowActivityDetails}
/>
{!isIosOrIpadosHost && (
<AgentOptionsCard
osqueryData={osqueryData}
@@ -911,7 +909,6 @@ const HostDetailsPage = ({
pathname={location.pathname}
onShowSoftwareDetails={setSelectedSoftwareDetails}
hostTeamId={host.team_id || 0}
hostPlatform={host.platform}
/>
{host?.platform === "darwin" && macadmins?.munki?.version && (
<MunkiIssuesCard
@@ -24,14 +24,12 @@
grid-auto-flow: column;
}
// Note: CSS for labels card spans the whole grid for iOS and iPadOS
// because activity card is missing
// No agent options card for i(Pad)OS, so extend Labels card vertically
&__details-panel--ios-grid.react-tabs__tab-panel--selected {
// Must be selected to show grid
grid-template-columns: 1fr;
grid-template-columns: 1fr 1fr;
grid-template-areas:
"about"
"labels";
"about about"
"activity labels";
grid-auto-flow: column;
}
@@ -5,10 +5,6 @@
position: relative;
min-height: 500px;
&__empty-feed {
min-height: 484px;
}
&__pagination {
position: absolute;
bottom: 0px;
@@ -5,10 +5,6 @@
position: relative;
min-height: 500px;
&__empty-feed {
min-height: 484px;
}
&__pagination {
position: absolute;
bottom: 0px;
@@ -12,15 +12,13 @@ import deviceAPI, {
IGetDeviceSoftwareResponse,
} from "services/entities/device_user";
import { IHostSoftware, ISoftware } from "interfaces/software";
import { DEFAULT_USE_QUERY_OPTIONS, SUPPORT_LINK } from "utilities/constants";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import { NotificationContext } from "context/notification";
import { AppContext } from "context/app";
import Card from "components/Card/Card";
import DataError from "components/DataError";
import Spinner from "components/Spinner";
import EmptyTable from "components/EmptyTable";
import CustomLink from "components/CustomLink";
import { generateSoftwareTableHeaders as generateHostSoftwareTableConfig } from "./HostSoftwareTableConfig";
import { generateSoftwareTableHeaders as generateDeviceSoftwareTableConfig } from "./DeviceSoftwareTableConfig";
@@ -42,7 +40,6 @@ interface IHostSoftwareProps {
queryParams: ReturnType<typeof parseHostSoftwareQueryParams>;
pathname: string;
hostTeamId: number;
hostPlatform: string;
onShowSoftwareDetails?: (software: IHostSoftware) => void;
isSoftwareEnabled?: boolean;
isMyDevicePage?: boolean;
@@ -60,6 +57,7 @@ export const parseHostSoftwareQueryParams = (queryParams: {
order_key?: string;
order_direction?: "asc" | "desc";
vulnerable?: string;
available_for_install?: string;
}) => {
const searchQuery = queryParams?.query ?? DEFAULT_SEARCH_QUERY;
const sortHeader = queryParams?.order_key ?? DEFAULT_SORT_HEADER;
@@ -69,6 +67,7 @@ export const parseHostSoftwareQueryParams = (queryParams: {
: DEFAULT_PAGE;
const pageSize = DEFAULT_PAGE_SIZE;
const vulnerable = queryParams.vulnerable === "true";
const availableForInstall = queryParams.available_for_install === "true";
return {
page,
@@ -77,6 +76,7 @@ export const parseHostSoftwareQueryParams = (queryParams: {
order_direction: sortDirection,
per_page: pageSize,
vulnerable,
available_for_install: availableForInstall,
};
};
@@ -88,7 +88,6 @@ const HostSoftware = ({
queryParams,
pathname,
hostTeamId = 0,
hostPlatform,
onShowSoftwareDetails,
isSoftwareEnabled = false,
isMyDevicePage = false,
@@ -105,8 +104,6 @@ const HostSoftware = ({
number | null
>(null);
const isIosOrIpadOs = hostPlatform === "ipados" || hostPlatform === "ios";
const {
data: hostSoftwareRes,
isLoading: hostSoftwareLoading,
@@ -132,7 +129,7 @@ const HostSoftware = ({
},
{
...DEFAULT_USE_QUERY_OPTIONS,
enabled: isSoftwareEnabled && !isMyDevicePage && !isIosOrIpadOs, // if disabled, we'll always show a generic "No software detected" message
enabled: isSoftwareEnabled && !isMyDevicePage, // if disabled, we'll always show a generic "No software detected" message
keepPreviousData: true,
staleTime: 7000,
}
@@ -239,26 +236,22 @@ const HostSoftware = ({
const data = isMyDevicePage ? deviceSoftwareRes : hostSoftwareRes;
const getHostSoftwareFilterFromQueryParams = () => {
const { vulnerable, available_for_install } = queryParams;
if (available_for_install) {
return "installableSoftware";
}
if (vulnerable) {
return "vulnerableSoftware";
}
return "allSoftware";
};
const renderHostSoftware = () => {
if (isLoading) {
return <Spinner />;
}
if (isIosOrIpadOs) {
return (
<EmptyTable
header="Software is not supported for this host"
info={
<>
Interested in viewing software for{" "}
{hostPlatform === "ios" ? "iPhones" : "iPads"}?{" "}
<CustomLink url={SUPPORT_LINK} text="Let us know" newTab />
</>
}
/>
);
}
return (
<>
{isError && <DataError />}
@@ -275,7 +268,7 @@ const HostSoftware = ({
searchQuery={queryParams.query}
page={queryParams.page}
pagePath={pathname}
vulnerable={queryParams.vulnerable}
hostSoftwareFilter={getHostSoftwareFilterFromQueryParams()}
pathPrefix={pathname}
/>
)}
@@ -4,6 +4,9 @@ import { InjectedRouter } from "react-router";
import { IGetHostSoftwareResponse } from "services/entities/hosts";
import { IGetDeviceSoftwareResponse } from "services/entities/device_user";
import { getNextLocationPath } from "utilities/helpers";
import { QueryParams } from "utilities/url";
import { ISoftwareDropdownFilterVal } from "pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers";
import TableContainer from "components/TableContainer";
import { ITableQueryData } from "components/TableContainer/TableContainer";
@@ -17,20 +20,26 @@ const DEFAULT_PAGE_SIZE = 20;
const baseClass = "host-software-table";
export const VULNERABLE_DROPDOWN_OPTIONS = [
export const DROPDOWN_OPTIONS = [
{
disabled: false,
label: "All software",
value: false,
value: "allSoftware",
helpText: "All software installed on your hosts.",
},
{
disabled: false,
label: "Vulnerable software",
value: true,
value: "vulnerableSoftware",
helpText:
"All software installed on your hosts with detected vulnerabilities.",
},
{
disabled: false,
label: "Available for install",
value: "installableSoftware",
helpText: "Software that can be installed on your hosts.",
},
] as const;
interface IHostSoftwareTableProps {
@@ -45,7 +54,7 @@ interface IHostSoftwareTableProps {
pagePath: string;
routeTemplate?: string;
pathPrefix: string;
vulnerable?: boolean;
hostSoftwareFilter: ISoftwareDropdownFilterVal;
}
const HostSoftwareTable = ({
@@ -60,38 +69,45 @@ const HostSoftwareTable = ({
pagePath,
routeTemplate,
pathPrefix,
vulnerable,
hostSoftwareFilter,
}: IHostSoftwareTableProps) => {
const handleVulnFilterDropdownChange = useCallback(
(isFilterVulnerable: boolean) => {
const handleFilterDropdownChange = useCallback(
(val: ISoftwareDropdownFilterVal) => {
const newParams: QueryParams = {
query: searchQuery,
order_key: sortHeader,
order_direction: sortDirection,
page: 0,
};
// mutually exclusive
if (val === "installableSoftware") {
newParams.available_for_install = true.toString();
} else if (val === "vulnerableSoftware") {
newParams.vulnerable = true.toString();
}
const nextPath = getNextLocationPath({
pathPrefix,
routeTemplate,
queryParams: {
query: searchQuery,
order_key: sortHeader,
order_direction: sortDirection,
page: 0,
vulnerable: isFilterVulnerable.toString(),
},
queryParams: newParams,
});
router.replace(nextPath);
},
[pathPrefix, routeTemplate, router, searchQuery, sortDirection, sortHeader]
);
const memoizedVulnFilterDropdown = useCallback(() => {
const memoizedFilterDropdown = useCallback(() => {
return (
<Dropdown
value={vulnerable}
className={`${baseClass}__vuln_dropdown`}
options={VULNERABLE_DROPDOWN_OPTIONS}
value={hostSoftwareFilter}
options={DROPDOWN_OPTIONS}
searchable={false}
onChange={handleVulnFilterDropdownChange}
onChange={handleFilterDropdownChange}
tableFilterDropdown
/>
);
}, [handleVulnFilterDropdownChange, vulnerable]);
}, [handleFilterDropdownChange, hostSoftwareFilter]);
const determineQueryParamChange = useCallback(
(newTableQuery: ITableQueryData) => {
const changedEntry = Object.entries(newTableQuery).find(([key, val]) => {
@@ -115,20 +131,16 @@ const HostSoftwareTable = ({
const generateNewQueryParams = useCallback(
(newTableQuery: ITableQueryData, changedParam: string) => {
const newQueryParam: Record<
string,
string | number | boolean | undefined
> = {
const newQueryParam: QueryParams = {
query: newTableQuery.searchQuery,
order_direction: newTableQuery.sortDirection,
order_key: newTableQuery.sortHeader,
page: changedParam === "pageIndex" ? newTableQuery.pageIndex : 0,
vulnerable,
};
return newQueryParam;
},
[vulnerable]
[]
);
// TODO: Look into useDebounceCallback with dependencies
@@ -167,13 +179,8 @@ const HostSoftwareTable = ({
}, [count, isSoftwareNotDetected]);
const memoizedEmptyComponent = useCallback(() => {
return (
<EmptySoftwareTable
isFilterVulnerable={vulnerable}
isNotDetectingSoftware={searchQuery === ""}
/>
);
}, [searchQuery, vulnerable]);
return <EmptySoftwareTable isNotDetectingSoftware={searchQuery === ""} />;
}, [searchQuery]);
return (
<div className={baseClass}>
@@ -191,7 +198,7 @@ const HostSoftwareTable = ({
inputPlaceHolder="Search by name"
onQueryChange={onQueryChange}
emptyComponent={memoizedEmptyComponent}
customControl={memoizedVulnFilterDropdown}
customControl={memoizedFilterDropdown}
showMarkAllPages={false}
isAllPagesSelected={false}
searchable
@@ -20,6 +20,7 @@ const TEST_PROPS: ISoftwareSelfServiceProps = {
order_direction: "asc",
per_page: 10,
vulnerable: true,
available_for_install: false,
},
router: createMockRouter(),
};
@@ -93,6 +94,7 @@ describe("SelfService", () => {
order_direction: "asc",
per_page: 10,
vulnerable: true,
available_for_install: false,
}}
router={createMockRouter()}
/>
@@ -5,7 +5,7 @@
.host-software-table {
.controls {
// vulnerable software dropdown filter
// software filter
.Select {
.Select-menu-outer {
width: 364px;
@@ -21,9 +21,6 @@
.Select > .Select-menu-outer {
left: -186px;
width: 360px;
.dropdown__help-text {
color: $ui-fleet-black-50;
}
}
.Select-control {
margin-top: 0;
@@ -10,6 +10,7 @@ import LogDestinationIndicator from "components/LogDestinationIndicator/LogDesti
import { ISchedulableQuery } from "interfaces/schedulable_query";
import TooltipTruncatedText from "components/TooltipTruncatedText";
import { CONTACT_FLEET_LINK } from "utilities/constants";
interface IManageQueryAutomationsModalProps {
isUpdatingAutomations: boolean;
@@ -171,11 +172,7 @@ const ManageQueryAutomationsModal = ({
<p>Automations currently run on macOS, Windows, and Linux hosts.</p>
<p>
Interested in query automations for your Chromebooks? &nbsp;
<CustomLink
url="https://fleetdm.com/contact"
text="Let us know"
newTab
/>
<CustomLink url={CONTACT_FLEET_LINK} text="Let us know" newTab />
</p>
</InfoBanner>
<Button
+6 -7
View File
@@ -1,3 +1,4 @@
import { ApplePlatform } from "interfaces/platform";
import sendRequest from "services";
import endpoints from "utilities/endpoints";
@@ -9,14 +10,15 @@ export interface IGetVppInfoResponse {
export interface IVppApp {
name: string;
bundle_identifier: string;
icon_url: string;
latest_version: string;
app_store_id: number; // FIXME: This seems to be coming back as a string but we're not seeing TS errors because there's an implicit any cast
app_store_id: string;
added: boolean;
platform: string; // "darwin" | "ios" | "ipados"
platform: ApplePlatform;
}
interface IGetVppAppsResponse {
export interface IGetVppAppsResponse {
app_store_apps: IVppApp[];
}
@@ -67,10 +69,7 @@ export default {
return sendRequest("GET", path);
},
addVppApp: (teamId: number, appStoreId: number) => {
// FIXME: API seems to expect app_store_id to be a string but we're not seeing TS errors because
// there's an implicit any cast on getVppApps response that is being fed into the appStoreId
// param (so it's being treated as a number even though it is really a string being passed in here)
addVppApp: (teamId: number, appStoreId: string) => {
const { MDM_APPLE_VPP_APPS } = endpoints;
return sendRequest("POST", MDM_APPLE_VPP_APPS, {
app_store_id: appStoreId,
+2
View File
@@ -50,9 +50,11 @@ export interface ISoftwareVersionsResponse {
export interface ISoftwareTitleResponse {
software_title: ISoftwareTitleDetails;
}
export interface ISoftwareVersionResponse {
software: ISoftwareVersion;
}
export interface ISoftwareVersionsQueryKey extends ISoftwareApiParams {
scope: "software-versions";
}
+3
View File
@@ -59,8 +59,11 @@ export const HOST_STATUS_WEBHOOK_WINDOW_DROPDOWN_OPTIONS: IDropdownOption[] = [
export const GITHUB_NEW_ISSUE_LINK =
"https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&template=bug-report.md";
export const SUPPORT_LINK = "https://fleetdm.com/support";
export const CONTACT_FLEET_LINK = "https://fleetdm.com/contact";
/** July 28, 2016 is the date of the initial commit to fleet/fleet. */
export const INITIAL_FLEET_DATE = "2016-07-28T00:00:00Z";