mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
relates to #24828 Finish up the UI integration for the API to edit a custom package - [x] Manual QA for all new/changed functionality
424 lines
12 KiB
TypeScript
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 />
|
|
"Installed" status won'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} • </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;
|