Fleet UI: Add policies table to the sw title details page (#28886)

This commit is contained in:
RachelElysia
2025-05-13 13:41:44 -04:00
committed by GitHub
parent 13cd8386e4
commit 8df6ea1fd0
36 changed files with 895 additions and 379 deletions
@@ -1,8 +1,8 @@
import React from "react";
import { InjectedRouter } from "react-router";
import ReactTooltip from "react-tooltip";
import { uniqueId } from "lodash";
import { SELF_SERVICE_TOOLTIP } from "pages/SoftwarePage/helpers";
import Icon from "components/Icon";
@@ -49,7 +49,8 @@ const installIconMap: Record<InstallType, installIconConfig> = {
{count === 1
? "A policy triggers install."
: `${count} policies trigger install.`}{" "}
End users can reinstall from <b>Fleet Desktop {">"} Self-service</b>.
<br /> End users can reinstall from
<br /> <b>Fleet Desktop {">"} Self-service</b>.
</>
),
},
@@ -74,6 +75,7 @@ const InstallIconWithTooltip = ({
}
const tooltipId = uniqueId();
return (
<div className={`${baseClass}__install-icon-with-tooltip`}>
<div
+17 -12
View File
@@ -68,6 +68,20 @@ export type SoftwareCategory =
| "Developer tools"
| "Productivity";
export interface ISoftwarePackageStatus {
installed: number;
pending_install: number;
failed_install: number;
pending_uninstall: number;
failed_uninstall: number;
}
export interface ISoftwareAppStoreAppStatus {
installed: number;
pending: number;
failed: number;
}
export interface ISoftwarePackage {
name: string;
last_install: string | null;
@@ -82,18 +96,13 @@ export interface ISoftwarePackage {
automatic_install?: boolean; // POST only
self_service: boolean;
icon_url: string | null;
status: {
installed: number;
pending_install: number;
failed_install: number;
pending_uninstall: number;
failed_uninstall: number;
};
status: ISoftwarePackageStatus;
automatic_install_policies?: ISoftwareInstallPolicy[] | null;
install_during_setup?: boolean;
labels_include_any: ILabelSoftwareTitle[] | null;
labels_exclude_any: ILabelSoftwareTitle[] | null;
categories?: SoftwareCategory[];
fleet_maintained_app_id?: number | null;
}
export const isSoftwarePackage = (
@@ -109,11 +118,7 @@ export interface IAppStoreApp {
icon_url: string;
self_service: boolean;
platform: typeof HOST_APPLE_PLATFORMS[number];
status: {
installed: number;
pending: number;
failed: number;
};
status: ISoftwareAppStoreAppStatus;
install_during_setup?: boolean;
automatic_install_policies?: ISoftwareInstallPolicy[] | null;
automatic_install?: boolean;
@@ -1,98 +0,0 @@
import React from "react";
import { Link } from "react-router";
import paths from "router/paths";
import { ISoftwareInstallPolicy } from "interfaces/software";
import { getPathWithQueryParams } from "utilities/url";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
const baseClass = "automatic-install-modal";
interface IPoliciesListItemProps {
teamId: number;
policy: ISoftwareInstallPolicy;
}
const PoliciesListItem = ({ teamId, policy }: IPoliciesListItemProps) => {
return (
<li key={policy.id} className={`${baseClass}__list-item`}>
<Link
to={getPathWithQueryParams(paths.EDIT_POLICY(policy.id), {
team_id: teamId,
})}
>
{policy.name}
</Link>
</li>
);
};
interface IPoliciesListProps {
teamId: number;
policies: ISoftwareInstallPolicy[];
}
const PoliciesList = ({ teamId, policies }: IPoliciesListProps) => {
return (
<ul className={`${baseClass}__list`}>
{policies.map((policy) => (
<PoliciesListItem key={policy.id} teamId={teamId} policy={policy} />
))}
</ul>
);
};
interface IAutomaticInstallModalProps {
teamId: number;
policies: ISoftwareInstallPolicy[];
onExit: () => void;
}
const AutomaticInstallModal = ({
teamId,
policies,
onExit,
}: IAutomaticInstallModalProps) => {
const description =
policies.length > 1 ? (
<>
Software will be installed when hosts fail any of these policies.{" "}
<CustomLink
newTab
text="Learn more"
url="https://fleetdm.com/learn-more-about/policy-automation-install-software"
/>
</>
) : (
<>
Software will be installed when hosts fail this policy.{" "}
<CustomLink
newTab
text="Learn more"
url="https://fleetdm.com/learn-more-about/policy-automation-install-software"
/>
</>
);
return (
<Modal
className={baseClass}
title="Automatic install"
onExit={onExit}
width="large"
>
<>
<p className={`${baseClass}__description`}>{description}</p>
<PoliciesList teamId={teamId} policies={policies} />
<div className="modal-cta-wrap">
<Button onClick={onExit}>Done</Button>
</div>
</>
</Modal>
);
};
export default AutomaticInstallModal;
@@ -1,23 +0,0 @@
.automatic-install-modal {
&__description {
margin: 0 0 $pad-large
}
&__list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid $ui-fleet-black-10;
border-radius: $border-radius-medium;
}
&__list-item {
border-bottom: 1px solid $ui-fleet-black-10;
padding: $pad-small $pad-large;
&:last-child {
border-bottom: 0;
}
}
}
@@ -1 +0,0 @@
export { default } from "./AutomaticInstallModal";
@@ -0,0 +1,82 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import InstallerDetailsWidget from "./InstallerDetailsWidget";
// Mock current time for time stamp test
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2024-05-08T10:00:00Z"));
});
afterAll(() => {
jest.useRealTimers();
});
describe("InstallerDetailsWidget", () => {
const defaultProps = {
softwareName: "Test Software",
installerType: "package" as const,
addedTimestamp: "2024-05-06T10:00:00Z",
versionInfo: <span>v1.2.3</span>,
isFma: false,
};
it("renders the package icon when installerType is 'package'", () => {
render(<InstallerDetailsWidget {...defaultProps} />);
expect(screen.queryByTestId("file-pkg-graphic")).toBeInTheDocument();
expect(screen.queryByTestId("software-icon")).not.toBeInTheDocument();
});
it("renders the software name", () => {
render(<InstallerDetailsWidget {...defaultProps} />);
expect(screen.getByText("Test Software")).toBeInTheDocument();
});
it("renders version info and relative time when addedTimestamp is present", () => {
render(<InstallerDetailsWidget {...defaultProps} />);
expect(screen.getByText("v1.2.3")).toBeInTheDocument();
expect(screen.getByText(/2 days ago/i)).toBeInTheDocument();
});
it("renders only version info when addedTimestamp is not present", () => {
render(
<InstallerDetailsWidget {...defaultProps} addedTimestamp={undefined} />
);
expect(screen.queryByText(/2 days ago/i)).not.toBeInTheDocument();
});
it("applies additional className if provided", () => {
render(
<InstallerDetailsWidget {...defaultProps} className="extra-class" />
);
const rootDiv = document.querySelector(
".installer-details-widget.extra-class"
);
expect(rootDiv).toBeInTheDocument();
});
it("renders custom package label", () => {
render(<InstallerDetailsWidget {...defaultProps} />);
expect(screen.getByText(/custom package/i)).toBeInTheDocument();
});
it("renders FMA label", () => {
render(<InstallerDetailsWidget {...defaultProps} isFma />);
expect(screen.getByText(/Fleet-maintained/i)).toBeInTheDocument();
});
it("renders VPP label", () => {
render(<InstallerDetailsWidget {...defaultProps} installerType="vpp" />);
expect(screen.getByText(/App Store \(VPP\)/i)).toBeInTheDocument();
});
it("InstallerName disables tooltip if not truncated", () => {
// useCheckTruncatedElement is mocked to return false
render(<InstallerDetailsWidget {...defaultProps} />);
// TooltipWrapper is mocked, so we just check that the child is rendered
expect(screen.getByText("Test Software")).toBeInTheDocument();
});
});
@@ -12,13 +12,13 @@ import Graphic from "components/Graphic";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
import TooltipWrapper from "components/TooltipWrapper";
const baseClass = "software-details-widget";
const baseClass = "installer-details-widget";
interface ISoftwareNameProps {
interface IInstallerNameProps {
name: string;
}
const SoftwareName = ({ name }: ISoftwareNameProps) => {
const InstallerName = ({ name }: IInstallerNameProps) => {
const titleRef = React.useRef<HTMLDivElement>(null);
const isTruncated = useCheckTruncatedElement(titleRef);
@@ -37,21 +37,30 @@ const SoftwareName = ({ name }: ISoftwareNameProps) => {
);
};
interface ISoftwareDetailsWidget {
const renderInstallerDisplayText = (installerType: string, isFma: boolean) => {
if (installerType === "package") {
return isFma ? "Fleet-maintained" : "Custom package";
}
return "App Store (VPP)";
};
interface IInstallerDetailsWidgetProps {
className?: string;
softwareName: string;
installerType: "package" | "vpp";
addedTimestamp?: string;
versionInfo?: JSX.Element;
isFma: boolean;
}
const SoftwareDetailsWidget = ({
const InstallerDetailsWidget = ({
className,
softwareName,
installerType,
addedTimestamp,
versionInfo,
}: ISoftwareDetailsWidget) => {
isFma,
}: IInstallerDetailsWidgetProps) => {
const classNames = classnames(baseClass, className);
const renderIcon = () => {
@@ -63,17 +72,26 @@ const SoftwareDetailsWidget = ({
};
const renderDetails = () => {
return !addedTimestamp ? (
versionInfo
) : (
const renderTimeStamp = () =>
addedTimestamp ? (
<>
{" "}
&bull;{" "}
<TooltipWrapper
tipContent={internationalTimeFormat(new Date(addedTimestamp))}
underline={false}
>
{addedFromNow(addedTimestamp)}
</TooltipWrapper>
</>
) : (
""
);
return (
<>
{versionInfo} &bull;{" "}
<TooltipWrapper
tipContent={internationalTimeFormat(new Date(addedTimestamp))}
underline={false}
>
{addedFromNow(addedTimestamp)}
</TooltipWrapper>
{renderInstallerDisplayText(installerType, isFma)} &bull; {versionInfo}
{renderTimeStamp()}
</>
);
};
@@ -82,11 +100,11 @@ const SoftwareDetailsWidget = ({
<div className={classNames}>
{renderIcon()}
<div className={`${baseClass}__info`}>
<SoftwareName name={softwareName} />
<InstallerName name={softwareName} />
<span className={`${baseClass}__details`}>{renderDetails()}</span>
</div>
</div>
);
};
export default SoftwareDetailsWidget;
export default InstallerDetailsWidget;
@@ -0,0 +1 @@
export { default } from "./InstallerDetailsWidget";
@@ -0,0 +1,25 @@
import React from "react";
import { screen, render } from "@testing-library/react";
import InstallerPoliciesTable from "./InstallerPoliciesTable";
describe("InstallerPoliciesTable", () => {
it("renders policy names as links and footer info", () => {
const policies = [{ id: 1, name: "No Gatekeeper" }];
render(<InstallerPoliciesTable teamId={42} policies={policies} />);
// There should be two cells, each with a link
const cells = screen.getAllByRole("cell");
expect(cells).toHaveLength(1);
// Each cell should contain a link with the policy name
expect(cells[0].querySelector("a.link-cell")).toHaveTextContent(
/No Gatekeeper/i
);
const POLICY_COUNT = /1 policy/i;
expect(screen.getByText(POLICY_COUNT)).toBeInTheDocument();
const FOOTER_TEXT = /Software will be installed when hosts fail/i;
expect(screen.getByText(FOOTER_TEXT)).toBeInTheDocument();
});
});
@@ -0,0 +1,64 @@
import React, { useCallback } from "react";
import classnames from "classnames";
import { ISoftwareInstallPolicy } from "interfaces/software";
import TableContainer from "components/TableContainer";
import TableCount from "components/TableContainer/TableCount";
import CustomLink from "components/CustomLink";
import generateInstallerPoliciesTableConfig from "./InstallerPoliciesTableConfig";
const baseClass = "installer-policies-table";
interface IInstallerPoliciesTable {
className?: string;
teamId?: number;
isLoading?: boolean;
policies?: ISoftwareInstallPolicy[] | null;
}
const InstallerPoliciesTable = ({
className,
teamId,
isLoading = false,
policies,
}: IInstallerPoliciesTable) => {
const classNames = classnames(baseClass, className);
const softwareStatusHeaders = generateInstallerPoliciesTableConfig({
teamId,
});
const renderInstallerPoliciesCount = useCallback(() => {
return <TableCount name="policies" count={policies?.length} />;
}, [policies?.length]);
const renderTableHelpText = () => (
<div>
Software will be installed when hosts fail{" "}
{policies?.length === 1 ? "this policy" : "any of these policies"}.{" "}
<CustomLink
url="https://fleetdm.com/learn-more-about/policy-automation-install-software"
text="Learn more"
newTab
/>
</div>
);
return (
<TableContainer
className={baseClass}
isLoading={isLoading}
columnConfigs={softwareStatusHeaders}
data={policies || []}
renderCount={renderInstallerPoliciesCount}
disablePagination
disableMultiRowSelect
emptyComponent={() => <></>}
showMarkAllPages={false}
isAllPagesSelected={false}
renderTableHelpText={renderTableHelpText}
/>
);
};
export default InstallerPoliciesTable;
@@ -0,0 +1,56 @@
import React from "react";
import { ISoftwareInstallPolicy } from "interfaces/software";
import PATHS from "router/paths";
import { getPathWithQueryParams } from "utilities/url";
import LinkCell from "components/TableContainer/DataTable/LinkCell";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
interface IInstallerPoliciesTableConfig {
teamId?: number;
}
interface ICellProps {
cell: {
value: string;
};
row: {
original: ISoftwareInstallPolicy;
};
column: {
isSortedDesc: boolean;
title: string;
};
}
const generateInstallerPoliciesTableConfig = ({
teamId,
}: IInstallerPoliciesTableConfig) => {
const tableHeaders = [
{
accessor: "name",
title: "Name",
Header: (cellProps: ICellProps) => (
<HeaderCell
value={cellProps.column.title}
isSortedDesc={cellProps.column.isSortedDesc}
/>
),
Cell: (cellProps: ICellProps) => (
<LinkCell
value={cellProps.cell.value}
path={getPathWithQueryParams(
PATHS.EDIT_POLICY(cellProps.row.original.id),
{
team_id: teamId,
}
)}
/>
),
},
];
return tableHeaders;
};
export default generateInstallerPoliciesTableConfig;
@@ -0,0 +1,7 @@
.installer-status-table {
&__status-title {
display: flex;
flex-direction: row;
gap: $pad-small;
}
}
@@ -0,0 +1 @@
export { default } from "./InstallerPoliciesTable";
@@ -0,0 +1,29 @@
import React from "react";
import { screen } from "@testing-library/react";
import { createCustomRenderer } from "test/test-utils";
import InstallerStatusTable from "./InstallerStatusTable";
describe("InstallerStatusTable", () => {
const render = createCustomRenderer();
it("renders columns and links for statuses", () => {
render(
<InstallerStatusTable
softwareId={123}
teamId={5}
status={{ installed: 0, pending: 1, failed: 3 }}
/>
);
// Check cell values (always "hosts", even for 1)
const cells = screen.getAllByRole("cell");
expect(cells[0]).toHaveTextContent("0 hosts");
expect(cells[1]).toHaveTextContent("1 host");
expect(cells[2]).toHaveTextContent("3 hosts");
// Check the anchor and its text in each cell
expect(cells[0].querySelector("a.link-cell")).toHaveTextContent("0 hosts");
expect(cells[1].querySelector("a.link-cell")).toHaveTextContent("1 host");
expect(cells[2].querySelector("a.link-cell")).toHaveTextContent("3 hosts");
});
});
@@ -0,0 +1,52 @@
import React from "react";
import classnames from "classnames";
import TableContainer from "components/TableContainer";
import {
ISoftwarePackageStatus,
ISoftwareAppStoreAppStatus,
} from "interfaces/software";
import generateSoftwareTitleDetailsTableConfig from "./InstallerStatusTableConfig";
const baseClass = "installer-status-table";
interface IInstallerStatusTableProps {
className?: string;
softwareId: number;
teamId?: number;
status: ISoftwarePackageStatus | ISoftwareAppStoreAppStatus;
isLoading?: boolean;
}
const InstallerStatusTable = ({
className,
softwareId,
teamId,
status,
isLoading = false,
}: IInstallerStatusTableProps) => {
const classNames = classnames(baseClass, className);
const softwareStatusHeaders = generateSoftwareTitleDetailsTableConfig({
baseClass: classNames,
softwareId,
teamId,
});
return (
<TableContainer
className={baseClass}
isLoading={isLoading}
columnConfigs={softwareStatusHeaders}
data={[status]}
disablePagination
disableMultiRowSelect
emptyComponent={() => <></>}
showMarkAllPages={false}
isAllPagesSelected={false}
disableHighlightOnHover
hideFooter
/>
);
};
export default InstallerStatusTable;
@@ -0,0 +1,191 @@
import React from "react";
import { ISoftwareTitleVersion } from "interfaces/software";
import PATHS from "router/paths";
import { getPathWithQueryParams } from "utilities/url";
import { generateResultsCountText } from "components/TableContainer/utilities/TableContainerUtils";
import LinkCell from "components/TableContainer/DataTable/LinkCell";
import TooltipWrapper from "components/TooltipWrapper";
import Icon from "components/Icon";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
interface ISoftwareTitleDetailsTableConfigProps {
softwareId?: number;
teamId?: number;
baseClass?: string;
}
interface ICellProps {
cell: {
value: number;
};
row: {
original: ISoftwareTitleVersion;
};
}
interface IStatusDisplayOption {
displayName: string;
iconName: "success" | "pending-outline" | "error";
tooltip: React.ReactNode;
}
// "pending" and "failed" each encompass both "_install" and "_uninstall" sub-statuses
type SoftwareInstallDisplayStatus = "installed" | "pending" | "failed";
const STATUS_DISPLAY_OPTIONS: Record<
SoftwareInstallDisplayStatus,
IStatusDisplayOption
> = {
installed: {
displayName: "Installed",
iconName: "success",
tooltip: (
<>
Software is installed on these hosts (install script finished
<br />
with exit code 0). Currently, if the software is uninstalled, the
<br />
&quot;Installed&quot; status won&apos;t be updated.
</>
),
},
pending: {
displayName: "Pending",
iconName: "pending-outline",
tooltip: (
<>
Fleet is installing/uninstalling or will
<br />
do so when the host comes online.
</>
),
},
failed: {
displayName: "Failed",
iconName: "error",
tooltip: (
<>
These hosts failed to install/uninstall software.
<br />
Click on a host to view error(s).
</>
),
},
};
const generateSoftwareTitleDetailsTableConfig = ({
softwareId,
teamId,
baseClass,
}: ISoftwareTitleDetailsTableConfigProps) => {
const tableHeaders = [
{
accessor: "installed",
disableSortBy: true,
title: "Installed",
Header: () => {
const displayData = STATUS_DISPLAY_OPTIONS.installed;
const titleWithTooltip = (
<TooltipWrapper
position="top"
tipContent={displayData.tooltip}
underline={false}
showArrow
tipOffset={10}
>
<div className={`${baseClass}__status-title`}>
<Icon name={displayData.iconName} />
<div>{displayData.displayName}</div>
</div>
</TooltipWrapper>
);
return <HeaderCell value={titleWithTooltip} disableSortBy />;
},
Cell: (cellProps: ICellProps) => {
return (
<LinkCell
value={generateResultsCountText("hosts", cellProps.cell.value)}
path={getPathWithQueryParams(PATHS.MANAGE_HOSTS, {
software_title_id: softwareId,
software_status: "installed",
team_id: teamId,
})}
/>
);
},
},
{
accessor: "pending",
disableSortBy: true,
title: "Pending",
Header: () => {
const displayData = STATUS_DISPLAY_OPTIONS.pending;
return (
<TooltipWrapper
position="top"
tipContent={displayData.tooltip}
underline={false}
showArrow
tipOffset={10}
>
<div className={`${baseClass}__status-title`}>
<Icon name={displayData.iconName} />
<div>{displayData.displayName}</div>
</div>
</TooltipWrapper>
);
},
Cell: (cellProps: ICellProps) => {
return (
<LinkCell
value={generateResultsCountText("hosts", cellProps.cell.value)}
path={getPathWithQueryParams(PATHS.MANAGE_HOSTS, {
software_title_id: softwareId,
software_status: "pending",
team_id: teamId,
})}
/>
);
},
},
{
accessor: "failed",
disableSortBy: true,
title: "Failed",
Header: () => {
const displayData = STATUS_DISPLAY_OPTIONS.failed;
return (
<TooltipWrapper
position="top"
tipContent={displayData.tooltip}
underline={false}
showArrow
tipOffset={10}
>
<div className={`${baseClass}__status-title`}>
<Icon name={displayData.iconName} />
<div>{displayData.displayName}</div>
</div>
</TooltipWrapper>
);
},
Cell: (cellProps: ICellProps) => {
return (
<LinkCell
value={generateResultsCountText("hosts", cellProps.cell.value)}
path={getPathWithQueryParams(PATHS.MANAGE_HOSTS, {
software_title_id: softwareId,
software_status: "failed",
team_id: teamId,
})}
/>
);
},
},
];
return tableHeaders;
};
export default generateSoftwareTitleDetailsTableConfig;
@@ -0,0 +1,7 @@
.installer-status-table {
&__status-title {
display: flex;
flex-direction: row;
gap: $pad-small;
}
}
@@ -0,0 +1 @@
export { default } from "./InstallerStatusTable";
@@ -1 +0,0 @@
export { default } from "./SoftwareDetailsWidget";
@@ -2,7 +2,6 @@
import React, { useCallback, useContext, useState } from "react";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import {
@@ -12,33 +11,32 @@ import {
} from "interfaces/software";
import softwareAPI from "services/entities/software";
import { getPathWithQueryParams } from "utilities/url";
import { SELF_SERVICE_TOOLTIP } from "pages/SoftwarePage/helpers";
import Card from "components/Card";
import ActionsDropdown from "components/ActionsDropdown";
import TooltipWrapper from "components/TooltipWrapper";
import DataSet from "components/DataSet";
import Icon from "components/Icon";
import Tag from "components/Tag";
import Button from "components/buttons/Button";
import endpoints from "utilities/endpoints";
import URL_PREFIX from "router/url_prefix";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
import CustomLink from "components/CustomLink";
import SoftwareDetailsWidget from "pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareDetailsWidget";
import InstallerDetailsWidget from "pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget";
import CategoriesEndUserExperienceModal from "pages/SoftwarePage/components/modals/CategoriesEndUserExperienceModal";
import DeleteSoftwareModal from "../DeleteSoftwareModal";
import EditSoftwareModal from "../EditSoftwareModal";
import {
APP_STORE_APP_DROPDOWN_OPTIONS,
SOFTWARE_PACKAGE_DROPDOWN_OPTIONS,
APP_STORE_APP_ACTION_OPTIONS,
SOFTWARE_PACKAGE_ACTION_OPTIONS,
downloadFile,
} from "./helpers";
import AutomaticInstallModal from "../AutomaticInstallModal";
import InstallerStatusTable from "./InstallerStatusTable";
import InstallerPoliciesTable from "./InstallerPoliciesTable";
const baseClass = "software-installer-card";
@@ -92,52 +90,6 @@ const STATUS_DISPLAY_OPTIONS: Record<
},
};
interface IInstallerStatusCountProps {
softwareId: number;
status: SoftwareInstallDisplayStatus;
count: number;
teamId?: number;
}
const InstallerStatusCount = ({
softwareId,
status,
count,
teamId,
}: IInstallerStatusCountProps) => {
const displayData = STATUS_DISPLAY_OPTIONS[status];
const linkUrl = getPathWithQueryParams(PATHS.MANAGE_HOSTS, {
software_title_id: softwareId,
software_status: status,
team_id: teamId,
});
return (
<DataSet
className={`${baseClass}__status`}
title={
<TooltipWrapper
position="top"
tipContent={displayData.tooltip}
underline={false}
showArrow
tipOffset={10}
>
<div className={`${baseClass}__status-title`}>
<Icon name={displayData.iconName} />
<div>{displayData.displayName}</div>
</div>
</TooltipWrapper>
}
value={
<a className={`${baseClass}__status-count`} href={linkUrl}>
{count} hosts
</a>
}
/>
);
};
interface IActionsDropdownProps {
installerType: "package" | "vpp";
onDownloadClick: () => void;
@@ -145,7 +97,7 @@ interface IActionsDropdownProps {
onEditSoftwareClick: () => void;
}
const SoftwareActionsDropdown = ({
const SoftwareActionButtons = ({
installerType,
onDownloadClick,
onDeleteClick,
@@ -155,26 +107,10 @@ const SoftwareActionsDropdown = ({
const { gitops_mode_enabled: gitOpsModeEnabled, repository_url: repoURL } =
config?.gitops || {};
const onSelect = (action: string) => {
switch (action) {
case "download":
onDownloadClick();
break;
case "delete":
onDeleteClick();
break;
case "edit":
onEditSoftwareClick();
break;
default:
// noop
}
};
let options =
installerType === "package"
? [...SOFTWARE_PACKAGE_DROPDOWN_OPTIONS]
: [...APP_STORE_APP_DROPDOWN_OPTIONS];
? [...SOFTWARE_PACKAGE_ACTION_OPTIONS]
: [...APP_STORE_APP_ACTION_OPTIONS];
if (gitOpsModeEnabled) {
const tooltipContent = (
@@ -206,15 +142,39 @@ const SoftwareActionsDropdown = ({
});
}
// Map action values to handlers
const actionHandlers = {
download: onDownloadClick,
delete: onDeleteClick,
edit: onEditSoftwareClick,
};
return (
<div className={`${baseClass}__actions`}>
<ActionsDropdown
className={`${baseClass}__software-actions-dropdown`}
onChange={onSelect}
placeholder="Actions"
menuAlign="right"
options={options}
/>
{options.map((option) => {
const ButtonContent = (
<Button
key={option.value}
className={`btn btn-link ${baseClass}__action-btn`}
disabled={option.disabled}
onClick={() =>
actionHandlers[option.value as keyof typeof actionHandlers]?.()
}
variant="icon"
>
<Icon name={option.iconName} color="core-fleet-blue" />
</Button>
);
// If there's a tooltip, wrap the button
return option.tooltipContent ? (
<TooltipWrapper key={option.value} tipContent={option.tooltipContent}>
{ButtonContent}
</TooltipWrapper>
) : (
ButtonContent
);
})}
</div>
);
};
@@ -234,6 +194,7 @@ interface ISoftwareInstallerCardProps {
softwareInstaller: ISoftwarePackage | IAppStoreApp;
onDelete: () => void;
refetchSoftwareTitle: () => void;
isLoading: boolean;
}
// NOTE: This component is dependent on having either a software package
@@ -250,10 +211,19 @@ const SoftwareInstallerCard = ({
teamId,
onDelete,
refetchSoftwareTitle,
isLoading,
}: ISoftwareInstallerCardProps) => {
const installerType = isSoftwarePackage(softwareInstaller)
? "package"
: "vpp";
const isFleetMaintainedApp =
"fleet_maintained_app_id" in softwareInstaller &&
!!softwareInstaller.fleet_maintained_app_id;
const {
automatic_install_policies: automaticInstallPolicies,
} = softwareInstaller;
const {
isGlobalAdmin,
isGlobalMaintainer,
@@ -265,9 +235,6 @@ const SoftwareInstallerCard = ({
const [showEditSoftwareModal, setShowEditSoftwareModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showAutomaticInstallModal, setShowAutomaticInstallModal] = useState(
false
);
const onEditSoftwareClick = () => {
setShowEditSoftwareModal(true);
@@ -340,26 +307,27 @@ const SoftwareInstallerCard = ({
<Card borderRadiusSize="xxlarge" includeShadow className={baseClass}>
<div className={`${baseClass}__row-1`}>
<div className={`${baseClass}__row-1--responsive`}>
<SoftwareDetailsWidget
<InstallerDetailsWidget
softwareName={softwareInstaller?.name || name}
installerType={installerType}
versionInfo={versionInfo}
addedTimestamp={addedTimestamp}
isFma={isFleetMaintainedApp}
/>
<div className={`${baseClass}__tags-wrapper`}>
{Array.isArray(softwareInstaller.automatic_install_policies) &&
softwareInstaller.automatic_install_policies.length > 0 && (
{Array.isArray(automaticInstallPolicies) &&
automaticInstallPolicies.length > 0 && (
<TooltipWrapper
showArrow
position="top"
tipContent="Click to see policy that triggers automatic install."
tipContent={
automaticInstallPolicies.length === 1
? "A policy triggers install."
: `${automaticInstallPolicies.length} policies trigger install.`
}
underline={false}
>
<Tag
icon="refresh"
text="Automatic install"
onClick={() => setShowAutomaticInstallModal(true)}
/>
<Tag icon="refresh" text="Automatic install" />
</TooltipWrapper>
)}
{isSelfService && (
@@ -376,7 +344,7 @@ const SoftwareInstallerCard = ({
</div>
<div className={`${baseClass}__actions-wrapper`}>
{showActions && (
<SoftwareActionsDropdown
<SoftwareActionButtons
installerType={installerType}
onDownloadClick={onDownloadClick}
onDeleteClick={onDeleteClick}
@@ -385,26 +353,23 @@ const SoftwareInstallerCard = ({
)}
</div>
</div>
<div className={`${baseClass}__installer-statuses`}>
<InstallerStatusCount
<div className={`${baseClass}__installer-status-table`}>
<InstallerStatusTable
softwareId={softwareId}
status="installed"
count={status.installed}
teamId={teamId}
/>
<InstallerStatusCount
softwareId={softwareId}
status="pending"
count={status.pending}
teamId={teamId}
/>
<InstallerStatusCount
softwareId={softwareId}
status="failed"
count={status.failed}
teamId={teamId}
status={status}
isLoading={isLoading}
/>
</div>
{automaticInstallPolicies && (
<div className={`${baseClass}__installer-policies-table`}>
<InstallerPoliciesTable
teamId={teamId}
isLoading={isLoading}
policies={automaticInstallPolicies}
/>
</div>
)}
{showEditSoftwareModal && (
<EditSoftwareModal
softwareId={softwareId}
@@ -424,15 +389,6 @@ const SoftwareInstallerCard = ({
onSuccess={onDeleteSuccess}
/>
)}
{showAutomaticInstallModal &&
softwareInstaller?.automatic_install_policies &&
softwareInstaller?.automatic_install_policies.length > 0 && (
<AutomaticInstallModal
teamId={teamId}
policies={softwareInstaller.automatic_install_policies}
onExit={() => setShowAutomaticInstallModal(false)}
/>
)}
</Card>
);
};
@@ -5,6 +5,15 @@
align-items: center;
gap: $pad-medium;
&__actions {
display: flex;
}
&__installer-status-table,
&__installer-policies-table {
width: 100%;
}
&__row-1 {
display: flex;
width: 100%;
@@ -1,24 +1,37 @@
const DOWNLOAD_OPTION = {
label: "Download",
import { IconNames } from "components/icons";
import { ReactNode } from "react";
type ISoftwareOption = {
value: string;
disabled: boolean;
iconName: IconNames;
tooltipContent?: ReactNode;
};
const DOWNLOAD_OPTION: ISoftwareOption = {
value: "download",
disabled: false,
iconName: "download",
};
const EDIT_OPTION = {
label: "Edit",
const EDIT_OPTION: ISoftwareOption = {
value: "edit",
disabled: false,
iconName: "pencil",
};
const DELETE_OPTION = {
label: "Delete",
const DELETE_OPTION: ISoftwareOption = {
value: "delete",
disabled: false,
iconName: "trash",
};
export const SOFTWARE_PACKAGE_DROPDOWN_OPTIONS = [
export const SOFTWARE_PACKAGE_ACTION_OPTIONS = [
DOWNLOAD_OPTION,
EDIT_OPTION,
DELETE_OPTION,
] as const;
export const APP_STORE_APP_DROPDOWN_OPTIONS = [
export const APP_STORE_APP_ACTION_OPTIONS = [
EDIT_OPTION,
DELETE_OPTION,
] as const;
@@ -0,0 +1,68 @@
/** software/titles/:id > First section */
import React from "react";
import { InjectedRouter } from "react-router";
import {
formatSoftwareType,
isIpadOrIphoneSoftwareSource,
ISoftwareTitleDetails,
} from "interfaces/software";
import Card from "components/Card";
import SoftwareDetailsSummary from "pages/SoftwarePage/components/cards/SoftwareDetailsSummary";
import TitleVersionsTable from "./TitleVersionsTable";
interface ISoftwareSummaryCard {
title: ISoftwareTitleDetails;
softwareId: number;
teamId?: number;
isAvailableForInstall?: boolean;
isLoading?: boolean;
router: InjectedRouter;
}
const baseClass = "software-summary-card";
const SoftwareSummaryCard = ({
teamId,
softwareId,
isAvailableForInstall,
title,
isLoading = false,
router,
}: ISoftwareSummaryCard) => {
// Hide versions card for tgz_packages only
if (title.source === "tgz_packages") return null;
return (
<Card borderRadiusSize="xxlarge" includeShadow className={baseClass}>
<SoftwareDetailsSummary
title={title.name}
type={formatSoftwareType(title)}
versions={title.versions?.length ?? 0}
hosts={title.hosts_count}
countsUpdatedAt={title.counts_updated_at}
queryParams={{
software_title_id: softwareId,
team_id: teamId,
}}
name={title.name}
source={title.source}
iconUrl={title.app_store_app ? title.app_store_app.icon_url : undefined}
/>
<TitleVersionsTable
router={router}
data={title.versions ?? []}
isLoading={isLoading}
teamIdForApi={teamId}
isIPadOSOrIOSApp={isIpadOrIphoneSoftwareSource(title.source)}
isAvailableForInstall={isAvailableForInstall}
countsUpdatedAt={title.counts_updated_at}
/>
</Card>
);
};
export default SoftwareSummaryCard;
@@ -0,0 +1,76 @@
import React from "react";
import { screen, render } from "@testing-library/react";
import { ISoftwareTitleVersion } from "interfaces/software";
import TitleVersionsTable from "./TitleVersionsTable";
// TODO: figure out how to mock the router properly.
const mockRouter = {
push: jest.fn(),
replace: jest.fn(),
goBack: jest.fn(),
goForward: jest.fn(),
go: jest.fn(),
setRouteLeaveHook: jest.fn(),
isActive: jest.fn(),
createHref: jest.fn(),
createPath: jest.fn(),
};
describe("TitleVersionsTable", () => {
it("renders version names as links and footer info", () => {
const versions = [
{ id: 10, version: "1.2.3", vulnerabilities: [] },
{ id: 11, version: "1.2.4", vulnerabilities: [] },
];
render(
<TitleVersionsTable
router={mockRouter}
data={versions}
isLoading={false}
teamIdForApi={42}
isIPadOSOrIOSApp={false}
countsUpdatedAt="2024-05-08T12:00:00Z"
/>
);
// There should be one cell with a link for the version
const cells = screen.getAllByRole("cell");
expect(cells).toHaveLength(8);
expect(screen.getByText(/1.2.3/i)).toBeInTheDocument();
expect(screen.getByText(/1.2.4/i)).toBeInTheDocument();
// Version count should be shown
expect(screen.getByText(/2 versions/i)).toBeInTheDocument();
// Last updated info should be shown
expect(screen.getByText(/updated/i)).toBeInTheDocument();
});
it("renders empty state if no versions detected", () => {
const versions: ISoftwareTitleVersion[] = [];
render(
<TitleVersionsTable
router={mockRouter}
data={versions}
isLoading={false}
teamIdForApi={42}
isIPadOSOrIOSApp={false}
countsUpdatedAt="2024-05-08T12:00:00Z"
/>
);
const cells = screen.queryAllByRole("cell");
expect(cells).toHaveLength(0);
// Version count should not be shown
expect(screen.queryByText(/0 versions/i)).not.toBeInTheDocument();
// Last updated info should be shown
expect(screen.getByText(/updated/i)).toBeInTheDocument();
// Empty state should be shown
expect(screen.getByText(/no versions detected/i)).toBeInTheDocument();
});
});
@@ -14,15 +14,17 @@ import TableCount from "components/TableContainer/TableCount";
import EmptyTable from "components/EmptyTable";
import CustomLink from "components/CustomLink";
import LastUpdatedText from "components/LastUpdatedText";
import Card from "components/Card";
import generateSoftwareTitleDetailsTableConfig from "./SoftwareTitleDetailsTableConfig";
import generateSoftwareTitleVersionsTableConfig from "./TitleVersionsTableConfig";
const DEFAULT_SORT_HEADER = "hosts_count";
const DEFAULT_SORT_DIRECTION = "desc";
const DEFAULT_PAGE_SIZE = 5;
const baseClass = "software-title-details-table";
const baseClass = "software-title-versions-table";
const SoftwareLastUpdatedInfo = (lastUpdatedAt: string) => {
const TitleVersionsLastUpdatedInfo = (lastUpdatedAt: string) => {
return (
<LastUpdatedText
lastUpdatedAt={lastUpdatedAt}
@@ -39,31 +41,33 @@ const SoftwareLastUpdatedInfo = (lastUpdatedAt: string) => {
const NoVersionsDetected = (isAvailableForInstall = false): JSX.Element => {
return (
<EmptyTable
header={
isAvailableForInstall
? "No versions detected."
: "No versions detected for this software item."
}
info={
isAvailableForInstall ? (
"Install this software on a host to see versions."
) : (
<>
Expecting to see versions?{" "}
<CustomLink
url={GITHUB_NEW_ISSUE_LINK}
text="File an issue on GitHub"
newTab
/>
</>
)
}
/>
<Card borderRadiusSize="medium">
<EmptyTable
header={
isAvailableForInstall
? "No versions detected."
: "No versions detected for this software item."
}
info={
isAvailableForInstall ? (
"Install this software on a host to see versions."
) : (
<>
Expecting to see versions?{" "}
<CustomLink
url={GITHUB_NEW_ISSUE_LINK}
text="File an issue on GitHub"
newTab
/>
</>
)
}
/>
</Card>
);
};
interface ISoftwareTitleDetailsTableProps {
interface ITitleVersionsTableProps {
router: InjectedRouter;
data: ISoftwareTitleVersion[];
isLoading: boolean;
@@ -79,7 +83,7 @@ interface IRowProps extends Row {
};
}
const SoftwareTitleDetailsTable = ({
const TitleVersionsTable = ({
router,
data,
isLoading,
@@ -87,7 +91,7 @@ const SoftwareTitleDetailsTable = ({
isIPadOSOrIOSApp,
isAvailableForInstall,
countsUpdatedAt,
}: ISoftwareTitleDetailsTableProps) => {
}: ITitleVersionsTableProps) => {
const handleRowSelect = (row: IRowProps) => {
if (row.original.id) {
const softwareVersionId = row.original.id;
@@ -103,18 +107,17 @@ const SoftwareTitleDetailsTable = ({
const softwareTableHeaders = useMemo(
() =>
generateSoftwareTitleDetailsTableConfig({
router,
generateSoftwareTitleVersionsTableConfig({
teamId: teamIdForApi,
isIPadOSOrIOSApp,
}),
[router, teamIdForApi, isIPadOSOrIOSApp]
[teamIdForApi, isIPadOSOrIOSApp]
);
const renderVersionsCount = () => (
<>
<TableCount name="versions" count={data?.length} />
{countsUpdatedAt && SoftwareLastUpdatedInfo(countsUpdatedAt)}
{data?.length > 0 && <TableCount name="versions" count={data?.length} />}
{countsUpdatedAt && TitleVersionsLastUpdatedInfo(countsUpdatedAt)}
</>
);
@@ -129,12 +132,14 @@ const SoftwareTitleDetailsTable = ({
isAllPagesSelected={false}
defaultSortHeader={DEFAULT_SORT_HEADER}
defaultSortDirection={DEFAULT_SORT_DIRECTION}
disablePagination
pageSize={DEFAULT_PAGE_SIZE}
isClientSidePagination
disableMultiRowSelect
onSelectSingleRow={handleRowSelect}
renderCount={renderVersionsCount}
hideFooter={data?.length <= DEFAULT_PAGE_SIZE} // Removes footer space
/>
);
};
export default SoftwareTitleDetailsTable;
export default TitleVersionsTable;
@@ -1,5 +1,4 @@
import React from "react";
import { InjectedRouter } from "react-router";
import {
ISoftwareTitleVersion,
@@ -12,10 +11,9 @@ import TextCell from "components/TableContainer/DataTable/TextCell";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import LinkCell from "components/TableContainer/DataTable/LinkCell";
import VulnerabilitiesCell from "../../components/tables/VulnerabilitiesCell";
import VulnerabilitiesCell from "../../../components/tables/VulnerabilitiesCell";
interface ISoftwareTitleDetailsTableConfigProps {
router: InjectedRouter;
interface ISoftwareTitleVersionsTableConfigProps {
teamId?: number;
isIPadOSOrIOSApp: boolean;
}
@@ -46,11 +44,10 @@ interface IVulnCellProps extends ICellProps {
};
}
const generateSoftwareTitleDetailsTableConfig = ({
router,
const generateSoftwareTitleVersionsTableConfig = ({
teamId,
isIPadOSOrIOSApp,
}: ISoftwareTitleDetailsTableConfigProps) => {
}: ISoftwareTitleVersionsTableConfigProps) => {
const tableHeaders = [
{
title: "Version",
@@ -132,4 +129,4 @@ const generateSoftwareTitleDetailsTableConfig = ({
return tableHeaders;
};
export default generateSoftwareTitleDetailsTableConfig;
export default generateSoftwareTitleVersionsTableConfig;
@@ -0,0 +1,5 @@
.software-title-versions-table {
.empty-table__container {
margin: $pad-small auto; // Creates 32px total
}
}
@@ -0,0 +1 @@
export { default } from "./TitleVersionsTable";
@@ -0,0 +1,2 @@
.software-summary-card {
}
@@ -0,0 +1 @@
export { default } from "./SoftwareSummaryCard";
@@ -10,11 +10,7 @@ import paths from "router/paths";
import useTeamIdParam from "hooks/useTeamIdParam";
import { AppContext } from "context/app";
import { ignoreAxiosError } from "interfaces/errors";
import {
ISoftwareTitleDetails,
formatSoftwareType,
isIpadOrIphoneSoftwareSource,
} from "interfaces/software";
import { ISoftwareTitleDetails } from "interfaces/software";
import {
APP_CONTEXT_ALL_TEAMS_ID,
APP_CONTEXT_NO_TEAM_ID,
@@ -30,11 +26,8 @@ import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import Spinner from "components/Spinner";
import MainContent from "components/MainContent";
import TeamsHeader from "components/TeamsHeader";
import Card from "components/Card";
import SoftwareDetailsSummary from "../components/cards/SoftwareDetailsSummary";
import SoftwareTitleDetailsTable from "./SoftwareTitleDetailsTable";
import DetailsNoHosts from "../components/cards/DetailsNoHosts";
import SoftwareSummaryCard from "./SoftwareSummaryCard";
import SoftwareInstallerCard from "./SoftwareInstallerCard";
import { getInstallerCardInfo } from "./helpers";
@@ -163,31 +156,21 @@ const SoftwareTitleDetailsPage = ({
teamId={currentTeamId ?? APP_CONTEXT_NO_TEAM_ID}
onDelete={onDeleteInstaller}
refetchSoftwareTitle={refetchSoftwareTitle}
isLoading={isSoftwareTitleLoading}
/>
);
};
const renderSoftwareVersionsCard = (title: ISoftwareTitleDetails) => {
// Hide versions card for tgz_packages only
if (title.source === "tgz_packages") return null;
const renderSoftwareSummaryCard = (title: ISoftwareTitleDetails) => {
return (
<Card
borderRadiusSize="xxlarge"
includeShadow
className={`${baseClass}__versions-section`}
>
<h2>Versions</h2>
<SoftwareTitleDetailsTable
router={router}
data={title.versions ?? []}
isLoading={isSoftwareTitleLoading}
teamIdForApi={teamIdForApi}
isIPadOSOrIOSApp={isIpadOrIphoneSoftwareSource(title.source)}
isAvailableForInstall={isAvailableForInstall}
countsUpdatedAt={title.counts_updated_at}
/>
</Card>
<SoftwareSummaryCard
title={title}
softwareId={softwareId}
teamId={teamIdForApi}
isAvailableForInstall={isAvailableForInstall}
isLoading={isSoftwareTitleLoading}
router={router}
/>
);
};
@@ -208,26 +191,8 @@ const SoftwareTitleDetailsPage = ({
if (softwareTitle) {
return (
<>
<SoftwareDetailsSummary
title={softwareTitle.name}
type={formatSoftwareType(softwareTitle)}
versions={softwareTitle.versions?.length ?? 0}
hosts={softwareTitle.hosts_count}
countsUpdatedAt={softwareTitle.counts_updated_at}
queryParams={{
software_title_id: softwareId,
team_id: teamIdForApi,
}}
name={softwareTitle.name}
source={softwareTitle.source}
iconUrl={
softwareTitle.app_store_app
? softwareTitle.app_store_app.icon_url
: undefined
}
/>
{renderSoftwareSummaryCard(softwareTitle)}
{renderSoftwareInstallerCard(softwareTitle)}
{renderSoftwareVersionsCard(softwareTitle)}
</>
);
}
@@ -1 +0,0 @@
export { default } from "./SoftwareTitleDetailsTable";
@@ -12,3 +12,9 @@
font-size: $small;
}
}
.software-summary-and-versions {
display: flex;
flex-direction: column;
gap: $pad-medium;
}
@@ -23,7 +23,7 @@ interface ISoftwareDetailsSummaryProps {
type?: string;
hosts: number;
countsUpdatedAt?: string;
/** The query param that will be added when user clicks on "View all hosts" link */
/** The query param that will be added when user clicks on the host count */
queryParams: QueryParams;
name?: string;
source?: string;
@@ -1,9 +1,4 @@
.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;