fleet/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx
Gabriel Hernandez e6efcf7238
finish UI api integration for editing a custom package (#24929)
relates to #24828

Finish up the UI integration for the API to edit a custom package

- [x] Manual QA for all new/changed functionality
2024-12-19 15:31:36 -06:00

424 lines
12 KiB
TypeScript

import React, {
useCallback,
useContext,
useLayoutEffect,
useState,
} from "react";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { ISoftwarePackage } from "interfaces/software";
import softwareAPI from "services/entities/software";
import { buildQueryStringFromParams } from "utilities/url";
import { internationalTimeFormat } from "utilities/helpers";
import { uploadedFromNow } from "utilities/date_format";
import Card from "components/Card";
import Graphic from "components/Graphic";
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 SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
import endpoints from "utilities/endpoints";
import URL_PREFIX from "router/url_prefix";
import DeleteSoftwareModal from "../DeleteSoftwareModal";
import EditSoftwareModal from "../EditSoftwareModal";
import {
APP_STORE_APP_DROPDOWN_OPTIONS,
SOFTWARE_PACKAGE_DROPDOWN_OPTIONS,
downloadFile,
} from "./helpers";
import AutomaticInstallModal from "../AutomaticInstallModal";
const baseClass = "software-package-card";
/** TODO: pull this hook and SoftwareName component out. We could use this other places */
function useTruncatedElement<T extends HTMLElement>(ref: React.RefObject<T>) {
const [isTruncated, setIsTruncated] = useState(false);
useLayoutEffect(() => {
const element = ref.current;
function updateIsTruncated() {
if (element) {
const { scrollWidth, clientWidth } = element;
setIsTruncated(scrollWidth > clientWidth);
}
}
window.addEventListener("resize", updateIsTruncated);
updateIsTruncated();
return () => window.removeEventListener("resize", updateIsTruncated);
}, [ref]);
return isTruncated;
}
interface ISoftwareNameProps {
name: string;
}
const SoftwareName = ({ name }: ISoftwareNameProps) => {
const titleRef = React.useRef<HTMLDivElement>(null);
const isTruncated = useTruncatedElement(titleRef);
return (
<TooltipWrapper
tipContent={name}
position="top"
underline={false}
disableTooltip={!isTruncated}
showArrow
>
<div ref={titleRef} className={`${baseClass}__title`}>
{name}
</div>
</TooltipWrapper>
);
};
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).
</>
),
},
};
interface IPackageStatusCountProps {
softwareId: number;
status: SoftwareInstallDisplayStatus;
count: number;
teamId?: number;
}
const PackageStatusCount = ({
softwareId,
status,
count,
teamId,
}: IPackageStatusCountProps) => {
const displayData = STATUS_DISPLAY_OPTIONS[status];
const linkUrl = `${PATHS.MANAGE_HOSTS}?${buildQueryStringFromParams({
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 {
isSoftwarePackage: boolean;
onDownloadClick: () => void;
onDeleteClick: () => void;
onEditSoftwareClick: () => void;
}
const SoftwareActionsDropdown = ({
isSoftwarePackage,
onDownloadClick,
onDeleteClick,
onEditSoftwareClick,
}: IActionsDropdownProps) => {
const onSelect = (value: string) => {
switch (value) {
case "download":
onDownloadClick();
break;
case "delete":
onDeleteClick();
break;
case "edit":
onEditSoftwareClick();
break;
default:
// noop
}
};
return (
<div className={`${baseClass}__actions`}>
<ActionsDropdown
className={`${baseClass}__host-actions-dropdown`}
onChange={onSelect}
placeholder="Actions"
isSearchable={false}
options={
isSoftwarePackage
? [...SOFTWARE_PACKAGE_DROPDOWN_OPTIONS]
: [...APP_STORE_APP_DROPDOWN_OPTIONS]
}
menuAlign="right"
/>
</div>
);
};
interface ISoftwarePackageCardProps {
name: string;
version: string;
uploadedAt: string; // TODO: optional?
status: {
installed: number;
pending: number;
failed: number;
};
isSelfService: boolean;
softwareId: number;
teamId: number;
// NOTE: we will only have this if we are working with a software package.
softwarePackage?: ISoftwarePackage;
onDelete: () => void;
refetchSoftwareTitle: () => void;
}
// NOTE: This component is dependent on having either a software package
// (ISoftwarePackage) or an app store app (IAppStoreApp). If we add more types
// of packages we should consider refactoring this to be more dynamic.
const SoftwarePackageCard = ({
name,
version,
uploadedAt,
status,
isSelfService,
softwarePackage,
softwareId,
teamId,
onDelete,
refetchSoftwareTitle,
}: ISoftwarePackageCardProps) => {
const {
isGlobalAdmin,
isGlobalMaintainer,
isTeamAdmin,
isTeamMaintainer,
} = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
const [showEditSoftwareModal, setShowEditSoftwareModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showAutomaticInstallModal, setShowAutomaticInstallModal] = useState(
false
);
const onEditSoftwareClick = () => {
setShowEditSoftwareModal(true);
};
const onDeleteClick = () => {
setShowDeleteModal(true);
};
const onDeleteSuccess = useCallback(() => {
setShowDeleteModal(false);
onDelete();
}, [onDelete]);
const onDownloadClick = useCallback(async () => {
try {
const resp = await softwareAPI.getSoftwarePackageToken(
softwareId,
teamId
);
if (!resp.token) {
throw new Error("No download token returned");
}
// Now that we received the download token, we construct the download URL.
const { origin } = global.window.location;
const url = `${origin}${URL_PREFIX}/api${endpoints.SOFTWARE_PACKAGE_TOKEN(
softwareId
)}/${resp.token}`;
// The download occurs without any additional authentication.
downloadFile(url, name);
} catch (e) {
renderFlash("error", "Couldn't download. Please try again.");
}
}, [renderFlash, softwareId, name, teamId]);
const renderIcon = () => {
return softwarePackage ? (
<Graphic name="file-pkg" />
) : (
<SoftwareIcon name="appStore" size="medium" />
);
};
const renderDetails = () => {
return !uploadedAt ? (
<span>Version {version}</span>
) : (
<>
<span>Version {version} &bull; </span>
<TooltipWrapper
tipContent={internationalTimeFormat(new Date(uploadedAt))}
underline={false}
>
{uploadedFromNow(uploadedAt)}
</TooltipWrapper>
</>
);
};
const showActions =
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
return (
<Card borderRadiusSize="xxlarge" includeShadow className={baseClass}>
<div className={`${baseClass}__row-1`}>
{/* TODO: main-info could be a seperate component as its reused on a couple
pages already. Come back and pull this into a component */}
<div className={`${baseClass}__main-info`}>
{renderIcon()}
<div className={`${baseClass}__info`}>
<SoftwareName name={softwarePackage?.name || name} />
<span className={`${baseClass}__details`}>{renderDetails()}</span>
</div>
</div>
<div className={`${baseClass}__actions-wrapper`}>
{softwarePackage?.automatic_install_policies &&
softwarePackage?.automatic_install_policies.length > 0 && (
<TooltipWrapper
showArrow
position="top"
tipContent="Click to see policy that triggers automatic install."
underline={false}
>
<Tag
icon="refresh"
text="Automatic install"
onClick={() => setShowAutomaticInstallModal(true)}
/>
</TooltipWrapper>
)}
{isSelfService && <Tag icon="user" text="Self-service" />}
{showActions && (
<SoftwareActionsDropdown
isSoftwarePackage={!!softwarePackage}
onDownloadClick={onDownloadClick}
onDeleteClick={onDeleteClick}
onEditSoftwareClick={onEditSoftwareClick}
/>
)}
</div>
</div>
<div className={`${baseClass}__package-statuses`}>
<PackageStatusCount
softwareId={softwareId}
status="installed"
count={status.installed}
teamId={teamId}
/>
<PackageStatusCount
softwareId={softwareId}
status="pending"
count={status.pending}
teamId={teamId}
/>
<PackageStatusCount
softwareId={softwareId}
status="failed"
count={status.failed}
teamId={teamId}
/>
</div>
{showEditSoftwareModal && softwarePackage && (
<EditSoftwareModal
softwareId={softwareId}
teamId={teamId}
software={softwarePackage}
onExit={() => setShowEditSoftwareModal(false)}
refetchSoftwareTitle={refetchSoftwareTitle}
/>
)}
{showDeleteModal && (
<DeleteSoftwareModal
softwareId={softwareId}
softwarePackageName={softwarePackage?.name}
teamId={teamId}
onExit={() => setShowDeleteModal(false)}
onSuccess={onDeleteSuccess}
/>
)}
{showAutomaticInstallModal &&
softwarePackage?.automatic_install_policies &&
softwarePackage?.automatic_install_policies.length > 0 && (
<AutomaticInstallModal
teamId={teamId}
policies={softwarePackage.automatic_install_policies}
onExit={() => setShowAutomaticInstallModal(false)}
/>
)}
</Card>
);
};
export default SoftwarePackageCard;