Update frontend to support software install activities (#18871)

This commit is contained in:
Sarah Gillespie
2024-05-09 15:45:53 -05:00
committed by GitHub
parent 643fc8314b
commit 21313fcb04
20 changed files with 405 additions and 50 deletions
+10 -5
View File
@@ -73,13 +73,15 @@ export enum ActivityType {
ResentConfigurationProfile = "resent_configuration_profile",
AddedSoftware = "added_software",
DeletedSoftware = "deleted_software",
InstalledSoftware = "installed_software",
}
// This is a subset of ActivityType that are shown only for the host past activities
export type IHostPastActivityType =
export type IHostActivityType =
| ActivityType.RanScript
| ActivityType.LockedHost
| ActivityType.UnlockedHost;
| ActivityType.UnlockedHost
| ActivityType.InstalledSoftware;
export interface IActivity {
created_at: string;
@@ -92,8 +94,9 @@ export interface IActivity {
details?: IActivityDetails;
}
export type IPastActivity = Omit<IActivity, "type"> & {
type: IHostPastActivityType;
export type IHostActivity = Omit<IActivity, "type" | "details"> & {
type: IHostActivityType;
details: IActivityDetails;
};
export interface IActivityDetails {
@@ -120,6 +123,7 @@ export interface IActivityDetails {
host_display_name?: string;
host_display_names?: string[];
host_ids?: number[];
host_id?: number;
host_platform?: string;
installed_from_dep?: boolean;
mdm_platform?: "microsoft" | "apple";
@@ -134,7 +138,8 @@ export interface IActivityDetails {
deadline_days?: number;
grace_period_days?: number;
stats?: ISchedulableQueryStats;
host_id?: number;
software_title?: string;
software_package?: string;
status?: string;
install_uuid?: string;
}
+29
View File
@@ -148,3 +148,32 @@ export const formatSoftwareType = ({
}
return type;
};
/*
* SoftwareInstallStatus represents the possible states of software install operations.
*
*/
export type SoftwareInstallStatus = "pending" | "installed" | "failed";
/**
*
* ISoftwareInstallResult is the shape of a software install result object
* returned by the Fleet API.
*
*/
export interface ISoftwareInstallResult {
install_uuid: string;
software_title: string;
software_title_id: number;
software_package: string;
host_id: number;
host_display_name: string;
status: SoftwareInstallStatus;
detail: string;
output: string;
pre_install_query_output: string;
post_install_script_output: string;
}
export interface ISoftwareInstallResults {
results: ISoftwareInstallResult;
}
@@ -15,6 +15,9 @@ import Button from "components/buttons/Button";
import Spinner from "components/Spinner";
// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
import { SoftwareInstallDetailsModal } from "pages/SoftwarePage/components/SoftwareInstallDetails";
import ActivityItem from "./ActivityItem";
import ScriptDetailsModal from "./components/ScriptDetailsModal/ScriptDetailsModal";
@@ -35,6 +38,7 @@ const ActivityFeed = ({
const [pageIndex, setPageIndex] = useState(0);
const [showShowQueryModal, setShowShowQueryModal] = useState(false);
const [showScriptDetailsModal, setShowScriptDetailsModal] = useState(false);
const [installedSoftwareUuid, setInstalledSoftwareUuid] = useState("");
const queryShown = useRef("");
const queryImpact = useRef<string | undefined>(undefined);
const scriptExecutionId = useRef("");
@@ -81,6 +85,7 @@ const ActivityFeed = ({
activityType: ActivityType,
details: IActivityDetails
) => {
console.log("activityType", activityType);
switch (activityType) {
case ActivityType.LiveQuery:
queryShown.current = details.query_sql ?? "";
@@ -93,6 +98,11 @@ const ActivityFeed = ({
scriptExecutionId.current = details.script_execution_id ?? "";
setShowScriptDetailsModal(true);
break;
case ActivityType.InstalledSoftware:
// installUuid.current = details.install_uuid ?? "";
// console.log("installUuid.current", installUuid.current);
setInstalledSoftwareUuid(details.install_uuid ?? "");
break;
default:
break;
}
@@ -184,6 +194,12 @@ const ActivityFeed = ({
onCancel={() => setShowScriptDetailsModal(false)}
/>
)}
{installedSoftwareUuid && (
<SoftwareInstallDetailsModal
installUuid={installedSoftwareUuid}
onCancel={() => setInstalledSoftwareUuid("")}
/>
)}
</div>
);
};
@@ -16,6 +16,7 @@ import Icon from "components/Icon";
import ReactTooltip from "react-tooltip";
import PremiumFeatureIconWithTooltip from "components/PremiumFeatureIconWithTooltip";
import { COLORS } from "styles/var/colors";
import { getSoftwareInstallStatusPredicate } from "pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem";
const baseClass = "activity-item";
@@ -854,6 +855,42 @@ const TAGGED_TEMPLATES = {
</>
);
},
installedSoftware: (
activity: IActivity,
onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void
) => {
const { details } = activity;
if (!details) {
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
}
console.log("onDetailsClick", onDetailsClick);
const {
host_display_name: hostName,
software_title: title,
status,
install_uuid,
} = details;
return (
<>
{" "}
{getSoftwareInstallStatusPredicate(status)} <b>{title}</b> software on{" "}
<b>{hostName}</b>.{" "}
<Button
className={`${baseClass}__show-query-link`}
variant="text-link"
onClick={() =>
onDetailsClick?.(ActivityType.InstalledSoftware, { install_uuid })
}
>
Show details{" "}
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
</Button>
</>
);
},
};
const getDetail = (
@@ -1033,6 +1070,9 @@ const getDetail = (
case ActivityType.DeletedSoftware: {
return TAGGED_TEMPLATES.deletedSoftware(activity);
}
case ActivityType.InstalledSoftware: {
return TAGGED_TEMPLATES.installedSoftware(activity, onDetailsClick);
}
default: {
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
@@ -41,9 +41,9 @@ const SoftwareDetailsSummary = ({
<dl className={`${baseClass}__info`}>
<h1>{title}</h1>
<dl className={`${baseClass}__description-list`}>
{type && <DataSet title="Type" value={type} />}
{!!type && <DataSet title="Type" value={type} />}
{versions && <DataSet title="Versions" value={versions} />}
{!!versions && <DataSet title="Versions" value={versions} />}
<DataSet title="Hosts" value={hosts === 0 ? "---" : hosts} />
</dl>
</dl>
@@ -0,0 +1,148 @@
import React from "react";
import { useQuery } from "react-query";
import {
ISoftwareInstallResult,
ISoftwareInstallResults,
SoftwareInstallStatus,
} from "interfaces/software";
import softwareAPI from "services/entities/software";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
import Textarea from "components/Textarea";
import DataError from "components/DataError/DataError";
import Spinner from "components/Spinner/Spinner";
import { IconNames } from "components/icons";
const baseClass = "software-install-details";
const STATUS_ICONS: Record<SoftwareInstallStatus, IconNames> = {
pending: "pending-outline",
installed: "success-outline",
failed: "error-outline",
} as const;
const STATUS_PREDICATES: Record<SoftwareInstallStatus, string> = {
pending: "will install",
installed: "installed",
failed: "failed to install",
} as const;
const StatusMessage = ({
result: { host_display_name, software_package, software_title, status },
}: {
result: ISoftwareInstallResult;
}) => {
return (
<div className={`${baseClass}__status-message`}>
<Icon name={STATUS_ICONS[status]} />
<span>
Fleet {STATUS_PREDICATES[status]} <b>{software_title}</b> (
{software_package}) on <b>{host_display_name}</b>
{status === "pending" ? " when it comes online" : ""}.
</span>
</div>
);
};
const OUTPUT_DISPLAY_LABELS = {
pre_install_query_output: "Pre-install condition",
output: "Software install output",
post_install_script_output: "Post-install script output",
} as const;
const Output = ({
displayKey,
result,
}: {
displayKey: keyof typeof OUTPUT_DISPLAY_LABELS;
result: ISoftwareInstallResult;
}) => {
return (
<div className={`${baseClass}__script-output`}>
{OUTPUT_DISPLAY_LABELS[displayKey]}:
<Textarea className={`${baseClass}__output-textarea`}>
{result[displayKey]}
</Textarea>
</div>
);
};
export const SoftwareInstallDetails = ({
installUuid,
}: {
installUuid: string;
}) => {
const { data: result, isLoading, isError } = useQuery<
ISoftwareInstallResults,
Error,
ISoftwareInstallResult
>(
["softwareInstallResults", installUuid],
() => {
return softwareAPI.getSoftwareInstallResult(installUuid);
},
{
refetchOnWindowFocus: false,
select: (data) => data.results,
}
);
if (isLoading) {
return <Spinner />;
} else if (isError) {
return <DataError description="Close this modal and try again." />;
} else if (!result) {
// FIXME: Find a better solution for this.
return <DataError description="No data returned." />;
}
return (
<>
<div className={`${baseClass}__software-install-details`}>
<StatusMessage result={result} />
{result.status !== "pending" && (
<>
{result.pre_install_query_output && (
<Output displayKey="pre_install_query_output" result={result} />
)}
{result.output && <Output displayKey="output" result={result} />}
{result.post_install_script_output && (
<Output displayKey="post_install_script_output" result={result} />
)}
</>
)}
</div>
</>
);
};
export const SoftwareInstallDetailsModal = ({
installUuid,
onCancel,
}: {
installUuid: string;
onCancel: () => void;
}) => {
return (
<Modal
title="Install details"
onExit={onCancel}
onEnter={onCancel}
className={baseClass}
>
<>
<div className={`${baseClass}__modal-content`}>
<SoftwareInstallDetails installUuid={installUuid} />
</div>
<div className="modal-cta-wrap">
<Button onClick={onCancel} variant="brand">
Done
</Button>
</div>
</>
</Modal>
);
};
@@ -0,0 +1,17 @@
.software-install-details {
.modal__content {
margin-top: $pad-xlarge;
}
&__status-message {
display: flex;
align-items: center;
gap: $pad-small;
margin: 0;
}
&__script-output {
padding-top: $pad-xlarge;
.textarea {
margin-top: $pad-medium;
}
}
}
@@ -0,0 +1,4 @@
export {
SoftwareInstallDetails,
SoftwareInstallDetailsModal,
} from "./SoftwareInstallDetails";
@@ -13,7 +13,7 @@ import { QueryContext } from "context/query";
import { NotificationContext } from "context/notification";
import activitiesAPI, {
IPastActivitiesResponse,
IHostActivitiesResponse,
IUpcomingActivitiesResponse,
} from "services/entities/activities";
import hostAPI from "services/entities/hosts";
@@ -58,6 +58,7 @@ import TabsWrapper from "components/TabsWrapper";
import MainContent from "components/MainContent";
import BackLink from "components/BackLink";
import ScriptDetailsModal from "pages/DashboardPage/cards/ActivityFeed/components/ScriptDetailsModal";
import { SoftwareInstallDetailsModal } from "pages/SoftwarePage/components/SoftwareInstallDetails";
import HostSummaryCard from "../cards/HostSummary";
import AboutCard from "../cards/About";
@@ -171,6 +172,7 @@ const HostDetailsPage = ({
const [selectedPolicy, setSelectedPolicy] = useState<IHostPolicy | null>(
null
);
const [softwareInstallUuid, setSoftwareInstallUuid] = useState("");
const [isUpdatingHost, setIsUpdatingHost] = useState(false);
const [refetchStartTime, setRefetchStartTime] = useState<number | null>(null);
const [showRefetchSpinner, setShowRefetchSpinner] = useState(false);
@@ -369,9 +371,9 @@ const HostDetailsPage = ({
isError: pastActivitiesIsError,
refetch: refetchPastActivities,
} = useQuery<
IPastActivitiesResponse,
IHostActivitiesResponse,
Error,
IPastActivitiesResponse,
IHostActivitiesResponse,
Array<{
scope: string;
pageIndex: number;
@@ -552,6 +554,9 @@ const HostDetailsPage = ({
case "ran_script":
setScriptDetailsId(details?.script_execution_id || "");
break;
case "installed_software":
setSoftwareInstallUuid(details?.install_uuid || "");
break;
default: // do nothing
}
},
@@ -582,7 +587,11 @@ const HostDetailsPage = ({
const onCancelScriptDetailsModal = useCallback(() => {
setScriptDetailsId("");
}, [setScriptDetailsId]);
}, []);
const onCancelSoftwareInstallDetailsModal = useCallback(() => {
setSoftwareInstallUuid("");
}, []);
const onTransferHostSubmit = async (team: ITeam) => {
setIsUpdatingHost(true);
@@ -967,6 +976,12 @@ const HostDetailsPage = ({
onCancel={onCancelScriptDetailsModal}
/>
)}
{!!softwareInstallUuid && (
<SoftwareInstallDetailsModal
installUuid={softwareInstallUuid}
onCancel={onCancelSoftwareInstallDetailsModal}
/>
)}
{showLockHostModal && (
<LockModal
id={host.id}
@@ -3,7 +3,7 @@ import { Tab, TabList, TabPanel, Tabs } from "react-tabs";
import { IActivityDetails } from "interfaces/activity";
import {
IPastActivitiesResponse,
IHostActivitiesResponse,
IUpcomingActivitiesResponse,
} from "services/entities/activities";
@@ -48,7 +48,7 @@ const UpcomingTooltip = () => {
interface IActivityProps {
activeTab: "past" | "upcoming";
activities?: IPastActivitiesResponse | IUpcomingActivitiesResponse;
activities?: IHostActivitiesResponse | IUpcomingActivitiesResponse;
isLoading?: boolean;
isError?: boolean;
upcomingCount: number;
@@ -101,7 +101,7 @@ const Activity = ({
</TabList>
<TabPanel>
<PastActivityFeed
activities={activities as IPastActivitiesResponse | undefined}
activities={activities as IHostActivitiesResponse | undefined}
onDetailsClick={onShowDetails}
isError={isError}
onNextPage={onNextPage}
@@ -2,8 +2,8 @@ import React from "react";
import {
ActivityType,
IHostPastActivityType,
IPastActivity,
IHostActivityType,
IHostActivity,
} from "interfaces/activity";
import { ShowActivityDetailsHandler } from "./Activity";
@@ -11,10 +11,11 @@ import { ShowActivityDetailsHandler } from "./Activity";
import RanScriptActivityItem from "./ActivityItems/RanScriptActivityItem";
import LockedHostActivityItem from "./ActivityItems/LockedHostActivityItem";
import UnlockedHostActivityItem from "./ActivityItems/UnlockedHostActivityItem";
import InstalledSoftwareActivityItem from "./ActivityItems/InstalledSoftwareActivityItem";
/** The component props that all host activity items must adhere to */
export interface IHostActivityItemComponentProps {
activity: IPastActivity;
activity: IHostActivity;
}
/** Used for activity items component that need a show details handler */
@@ -24,11 +25,12 @@ export interface IHostActivityItemComponentPropsWithShowDetails
}
export const pastActivityComponentMap: Record<
IHostPastActivityType,
IHostActivityType,
| React.FC<IHostActivityItemComponentProps>
| React.FC<IHostActivityItemComponentPropsWithShowDetails>
> = {
[ActivityType.RanScript]: RanScriptActivityItem,
[ActivityType.LockedHost]: LockedHostActivityItem,
[ActivityType.UnlockedHost]: UnlockedHostActivityItem,
[ActivityType.InstalledSoftware]: InstalledSoftwareActivityItem,
};
@@ -0,0 +1,46 @@
import React from "react";
import { SoftwareInstallStatus } from "interfaces/software";
import { IHostActivityItemComponentPropsWithShowDetails } from "../../ActivityConfig";
import HostActivityItem from "../../HostActivityItem";
import ShowDetailsButton from "../../ShowDetailsButton";
const baseClass = "installed-software-activity-item";
const STATUS_PREDICATES: Record<SoftwareInstallStatus, string> = {
failed: "failed to install",
installed: "installed",
pending: "told Fleet to install",
} as const;
export const getSoftwareInstallStatusPredicate = (
status: string | undefined
) => {
if (!status) {
return STATUS_PREDICATES.pending;
}
return (
STATUS_PREDICATES[status as SoftwareInstallStatus] ||
STATUS_PREDICATES.pending
);
};
const InstalledSoftwareActivityItem = ({
activity,
onShowDetails,
}: IHostActivityItemComponentPropsWithShowDetails) => {
const { actor_full_name: actorName, details } = activity;
const { status, software_title: title } = details;
return (
<HostActivityItem className={baseClass} activity={activity}>
<b>{actorName}</b> {getSoftwareInstallStatusPredicate(status)}{" "}
<b>{title}</b> software on this host.{" "}
<ShowDetailsButton activity={activity} onShowDetails={onShowDetails} />
</HostActivityItem>
);
};
export default InstalledSoftwareActivityItem;
@@ -0,0 +1 @@
export { default } from "./InstalledSoftwareActivityItem";
@@ -1,7 +1,7 @@
import React from "react";
import { IPastActivity } from "interfaces/activity";
import { IPastActivitiesResponse } from "services/entities/activities";
import { IHostActivity } from "interfaces/activity";
import { IHostActivitiesResponse } from "services/entities/activities";
// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
@@ -16,7 +16,7 @@ import { pastActivityComponentMap } from "../ActivityConfig";
const baseClass = "past-activity-feed";
interface IPastActivityFeedProps {
activities?: IPastActivitiesResponse;
activities?: IHostActivitiesResponse;
isError?: boolean;
onDetailsClick: ShowActivityDetailsHandler;
onNextPage: () => void;
@@ -53,7 +53,7 @@ const PastActivityFeed = ({
return (
<div className={baseClass}>
<div>
{activitiesList.map((activity: IPastActivity) => {
{activitiesList.map((activity: IHostActivity) => {
const ActivityItemComponent = pastActivityComponentMap[activity.type];
return (
<ActivityItemComponent
@@ -2,7 +2,7 @@ import React from "react";
import ReactTooltip from "react-tooltip";
import { formatDistanceToNowStrict } from "date-fns";
import { IActivity } from "interfaces/activity";
import { ActivityType, IHostActivity } from "interfaces/activity";
import { COLORS } from "styles/var/colors";
import { DEFAULT_GRAVATAR_LINK } from "utilities/constants";
import {
@@ -19,10 +19,39 @@ import { ShowActivityDetailsHandler } from "../Activity";
const baseClass = "upcoming-activity";
interface IUpcomingActivityProps {
activity: IActivity;
activity: IHostActivity;
onDetailsClick: ShowActivityDetailsHandler;
}
const formatPredicate = ({ type, details }: IHostActivity) => {
switch (type) {
case ActivityType.RanScript:
return (
<>
told Fleet to run{" "}
{formatScriptNameForActivityItem(details?.script_name)}
</>
);
case ActivityType.InstalledSoftware:
return (
<>
told Fleet to install{" "}
{details?.software_title ? (
<>
<b>{details.software_title}</b>{" "}
</>
) : (
""
)}
software
</>
);
default:
// this should never happen
return <>{type}</>;
}
};
// TODO: Combine this with ./UpcomingActivity/UpcomingActivity.tsx and
// frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx
const UpcomingActivity = ({
@@ -46,23 +75,16 @@ const UpcomingActivity = ({
<div className={`${baseClass}__details-wrapper`}>
<div className="activity-details">
<span className={`${baseClass}__details-topline`}>
<b>{activity.actor_full_name}</b>
<>
{" "}
told Fleet to run{" "}
{formatScriptNameForActivityItem(
activity.details?.script_name
)}{" "}
on this host.{" "}
<Button
className={`${baseClass}__show-query-link`}
variant="text-link"
onClick={() => onDetailsClick?.(activity)}
>
Show details{" "}
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
</Button>
</>
<b>{activity.actor_full_name}</b> {formatPredicate(activity)} on
this host.{" "}
<Button
className={`${baseClass}__show-query-link`}
variant="text-link"
onClick={() => onDetailsClick?.(activity)}
>
Show details{" "}
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
</Button>
</span>
<br />
<span
@@ -1,7 +1,7 @@
import React from "react";
import { IActivity } from "interfaces/activity";
import { IActivitiesResponse } from "services/entities/activities";
import { IHostActivity } from "interfaces/activity";
import { IUpcomingActivitiesResponse } from "services/entities/activities";
// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
@@ -15,7 +15,7 @@ import { ShowActivityDetailsHandler } from "../Activity";
const baseClass = "upcoming-activity-feed";
interface IUpcomingActivityFeedProps {
activities?: IActivitiesResponse;
activities?: IUpcomingActivitiesResponse;
isError?: boolean;
onDetailsClick: ShowActivityDetailsHandler;
onNextPage: () => void;
@@ -52,8 +52,9 @@ const UpcomingActivityFeed = ({
return (
<div className={baseClass}>
<div>
{activitiesList.map((activity: IActivity) => (
{activitiesList.map((activity: IHostActivity) => (
<UpcomingActivity
key={activity.id}
activity={activity}
onDetailsClick={onDetailsClick}
/>
+5 -5
View File
@@ -1,5 +1,5 @@
import endpoints from "utilities/endpoints";
import { IActivity, IPastActivity } from "interfaces/activity";
import { IActivity, IHostActivity } from "interfaces/activity";
import sendRequest from "services";
import { buildQueryStringFromParams } from "utilities/url";
@@ -16,15 +16,15 @@ export interface IActivitiesResponse {
};
}
export interface IPastActivitiesResponse {
activities: IPastActivity[] | null;
export interface IHostActivitiesResponse {
activities: IHostActivity[] | null;
meta: {
has_next_results: boolean;
has_previous_results: boolean;
};
}
export interface IUpcomingActivitiesResponse extends IActivitiesResponse {
export interface IUpcomingActivitiesResponse extends IHostActivitiesResponse {
count: number;
}
@@ -53,7 +53,7 @@ export default {
id: number,
page = DEFAULT_PAGE,
perPage = DEFAULT_PAGE_SIZE
): Promise<IPastActivitiesResponse> => {
): Promise<IHostActivitiesResponse> => {
const { HOST_PAST_ACTIVITIES } = endpoints;
const queryParams = {
+6
View File
@@ -231,4 +231,10 @@ export default {
})}`;
return sendRequest("GET", path);
},
getSoftwareInstallResult: (installUuid: string) => {
const { SOFTWARE_INSTALL_RESULTS } = endpoints;
const path = SOFTWARE_INSTALL_RESULTS(installUuid);
return sendRequest("GET", path);
},
};
+2
View File
@@ -131,6 +131,8 @@ export default {
SOFTWARE_PACKAGE_ADD: `/${API_VERSION}/fleet/software/package`,
SOFTWARE_PACKAGE: (id: number) =>
`/${API_VERSION}/fleet/software/packages/${id}`,
SOFTWARE_INSTALL_RESULTS: (uuid: string) =>
`/${API_VERSION}/fleet/software/install/results/${uuid}`,
// AI endpoints
AUTOFILL_POLICY: `/${API_VERSION}/fleet/autofill/policy`,
+1
View File
@@ -55,6 +55,7 @@ import {
} from "utilities/constants";
import { ISchedulableQueryStats } from "interfaces/schedulable_query";
import { IDropdownOption } from "interfaces/dropdownOption";
import { IActivityDetails } from "interfaces/activity";
const ORG_INFO_ATTRS = ["org_name", "org_logo_url"];
const ADMIN_ATTRS = ["email", "name", "password", "password_confirmation"];