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(ref: React.RefObject) { 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(null); const isTruncated = useTruncatedElement(titleRef); return (
{name}
); }; 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
with exit code 0). Currently, if the software is uninstalled, the
"Installed" status won't be updated. ), }, pending: { displayName: "Pending", iconName: "pending-outline", tooltip: ( <> Fleet is installing/uninstalling or will
do so when the host comes online. ), }, failed: { displayName: "Failed", iconName: "error", tooltip: ( <> These hosts failed to install/uninstall software.
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 (
{displayData.displayName}
} value={ {count} hosts } /> ); }; 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 (
); }; 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 ? ( ) : ( ); }; const renderDetails = () => { return !uploadedAt ? ( Version {version} ) : ( <> Version {version} • {uploadedFromNow(uploadedAt)} ); }; const showActions = isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer; return (
{/* TODO: main-info could be a seperate component as its reused on a couple pages already. Come back and pull this into a component */}
{renderIcon()}
{renderDetails()}
{softwarePackage?.automatic_install_policies && softwarePackage?.automatic_install_policies.length > 0 && ( setShowAutomaticInstallModal(true)} /> )} {isSelfService && } {showActions && ( )}
{showEditSoftwareModal && softwarePackage && ( setShowEditSoftwareModal(false)} refetchSoftwareTitle={refetchSoftwareTitle} /> )} {showDeleteModal && ( setShowDeleteModal(false)} onSuccess={onDeleteSuccess} /> )} {showAutomaticInstallModal && softwarePackage?.automatic_install_policies && softwarePackage?.automatic_install_policies.length > 0 && ( setShowAutomaticInstallModal(false)} /> )}
); }; export default SoftwarePackageCard;