mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
Backend PR: #44511 <!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #41422 <img width="618" height="244" alt="image" src="https://github.com/user-attachments/assets/c223e37d-7051-46a6-a2ea-6bd1bdcbb53e" /> <img width="777" height="780" alt="image" src="https://github.com/user-attachments/assets/3b9ef4e9-2181-406b-a22e-e6773eba67af" /> <img width="649" height="236" alt="image" src="https://github.com/user-attachments/assets/3985faf0-a1e4-404a-b190-cb623f52339a" /> <img width="1083" height="768" alt="image" src="https://github.com/user-attachments/assets/2d4df607-4b34-435c-88db-6dc0fa09db2e" /> # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. Part of backend PR - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [x] Timeouts are implemented and retries are limited to avoid infinite loops - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added "Enrollment profile renewal failed" activity type and label. * Failure entries now appear in activity feeds and host details with a dedicated activity item and a details flow. * Users can open a failure details modal showing a status icon, host name (with fallback), relative failure time, guidance about certificate expiration, and a link to Fleet support. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
248 lines
6.7 KiB
TypeScript
248 lines
6.7 KiB
TypeScript
import React from "react";
|
|
import { useQuery } from "react-query";
|
|
import { formatDistanceToNow } from "date-fns";
|
|
|
|
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
|
|
|
|
import { ICommandResult } from "interfaces/command";
|
|
|
|
import commandApi, {
|
|
IGetCommandResultsResponse,
|
|
IGetHostCommandResultsQueryKey,
|
|
} from "services/entities/command";
|
|
|
|
import InputField from "components/forms/fields/InputField";
|
|
import Modal from "components/Modal";
|
|
import Spinner from "components/Spinner";
|
|
import DataError from "components/DataError";
|
|
import IconStatusMessage from "components/IconStatusMessage";
|
|
import { IconNames } from "components/icons";
|
|
import ModalFooter from "components/ModalFooter";
|
|
import Button from "components/buttons/Button";
|
|
|
|
const baseClass = "command-details-modal";
|
|
|
|
export const GetIconName = (status: string): IconNames => {
|
|
switch (status) {
|
|
case "Error":
|
|
return "error";
|
|
case "CommandFormatError":
|
|
return "error";
|
|
case "Acknowledged":
|
|
return "success";
|
|
case "Pending":
|
|
return "pending-outline";
|
|
case "NotNow":
|
|
return "pending-outline";
|
|
|
|
default:
|
|
// FIXME: update for other platforms and design appropriate default handling for unknown
|
|
// statuses; for now, just return warning icon to indicate unknown state
|
|
return "warning";
|
|
}
|
|
};
|
|
|
|
const getStatusMessage = (result: ICommandResult): React.ReactNode => {
|
|
const displayTime = result.updated_at
|
|
? ` (${formatDistanceToNow(new Date(result.updated_at), {
|
|
includeSeconds: true,
|
|
addSuffix: true,
|
|
})})`
|
|
: null;
|
|
|
|
const namePart = result.name ? (
|
|
<>
|
|
{" "}
|
|
for <b>{result.name}</b>
|
|
</>
|
|
) : null;
|
|
|
|
switch (result.status) {
|
|
case "CommandFormatError":
|
|
case "Error":
|
|
return (
|
|
<span>
|
|
The <b>{result.request_type}</b> command{namePart} failed on{" "}
|
|
<b>{result.hostname}</b>
|
|
{displayTime}.
|
|
</span>
|
|
);
|
|
|
|
case "Acknowledged":
|
|
return (
|
|
<span>
|
|
The <b>{result.request_type}</b> command{namePart} was acknowledged by{" "}
|
|
<b>{result.hostname}</b>
|
|
{displayTime}.
|
|
</span>
|
|
);
|
|
|
|
case "Pending":
|
|
return (
|
|
<span>
|
|
The <b>{result.request_type}</b> command{namePart} is pending on{" "}
|
|
<b>{result.hostname}</b>.
|
|
</span>
|
|
);
|
|
|
|
case "NotNow":
|
|
return (
|
|
<span>
|
|
The <b>{result.request_type}</b> command{namePart} is deferred on{" "}
|
|
<b>{result.hostname}</b> because the host was locked or was running on
|
|
battery power while in Power Nap. Fleet will try again.
|
|
</span>
|
|
);
|
|
|
|
default:
|
|
// FIXME: update for other platforms and design appropriate default handling for unknown
|
|
// statuses; for now, just fallback to status string
|
|
return <span>{`Status: ${result.status}`}</span>;
|
|
}
|
|
};
|
|
|
|
const defaultModalContentBody = (baseclass: string, result: ICommandResult) => (
|
|
<IconStatusMessage
|
|
className={`${baseclass}__status-message`}
|
|
iconName={GetIconName(result.status)}
|
|
message={getStatusMessage(result)}
|
|
/>
|
|
);
|
|
|
|
const ModalContent = ({
|
|
data,
|
|
isLoading,
|
|
error,
|
|
contentBody = defaultModalContentBody,
|
|
}: {
|
|
data: IGetCommandResultsResponse | undefined;
|
|
isLoading: boolean;
|
|
error: Error | null;
|
|
contentBody?: (baseClass: string, result: ICommandResult) => React.ReactNode;
|
|
}) => {
|
|
if (isLoading) {
|
|
return <Spinner />;
|
|
}
|
|
|
|
if (error) {
|
|
return <DataError description="Close this modal and try again." />;
|
|
}
|
|
|
|
if (!data?.results?.[0]) {
|
|
// this should not happen, but just in case
|
|
console.error("No results found in MDM command results data");
|
|
return <DataError description="Close this modal and try again." />;
|
|
}
|
|
|
|
if (data.results.length > 1) {
|
|
// this should not happen, but just in case
|
|
console.error(
|
|
`Expected one result, but found ${data.results.length} results.`
|
|
);
|
|
return <DataError description="Close this modal and try again." />;
|
|
}
|
|
|
|
const result = data.results[0];
|
|
|
|
return (
|
|
<div className={`${baseClass}__modal-content`}>
|
|
{contentBody(baseClass, result)}
|
|
{!!result.payload && (
|
|
<InputField
|
|
type="textarea"
|
|
label="Request payload:"
|
|
value={result.payload}
|
|
readOnly
|
|
enableCopy
|
|
/>
|
|
)}
|
|
{!!result.result && (
|
|
<InputField
|
|
type="textarea"
|
|
label={
|
|
<>
|
|
Response from <b>{result.hostname}</b>:
|
|
</>
|
|
}
|
|
value={result.result}
|
|
readOnly
|
|
enableCopy
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type ICommandResultsModalCommand = {
|
|
host_uuid?: string;
|
|
command_uuid: string;
|
|
};
|
|
|
|
interface ICommandResultsModalProps {
|
|
command: ICommandResultsModalCommand;
|
|
// contentBody if provided will be used to render content above the request and response payloads.
|
|
// if not defined, a default contentBody will be used to display a status message and icon based on profile status
|
|
contentBody?: (baseClass: string, result: ICommandResult) => React.ReactNode;
|
|
title?: string;
|
|
onDone: () => void;
|
|
}
|
|
|
|
const CommandResultsModal = ({
|
|
command: { host_uuid: host_identifier, command_uuid },
|
|
contentBody,
|
|
title = "MDM command details",
|
|
onDone,
|
|
}: ICommandResultsModalProps) => {
|
|
const { data, isLoading, error } = useQuery<
|
|
IGetCommandResultsResponse,
|
|
Error,
|
|
IGetCommandResultsResponse,
|
|
IGetHostCommandResultsQueryKey[]
|
|
>(
|
|
[
|
|
{
|
|
scope: "command_results",
|
|
host_identifier: host_identifier ?? "",
|
|
command_uuid,
|
|
},
|
|
],
|
|
async ({ queryKey }) => {
|
|
const resp =
|
|
queryKey[0].host_identifier === ""
|
|
? // if host_identifier is not provided, use the getCommandResults endpoint which does not require host_identifier
|
|
await commandApi.getCommandResults(queryKey[0].command_uuid)
|
|
: await commandApi.getHostCommandResults(queryKey[0]);
|
|
|
|
if (!resp?.results) {
|
|
// this should not happen, but just in case return the response as is
|
|
return resp;
|
|
}
|
|
return {
|
|
results: resp.results.map?.((r) => ({
|
|
...r,
|
|
payload: atob(r.payload),
|
|
result: atob(r.result),
|
|
})),
|
|
};
|
|
},
|
|
{
|
|
...DEFAULT_USE_QUERY_OPTIONS,
|
|
keepPreviousData: true,
|
|
staleTime: 2000,
|
|
}
|
|
);
|
|
|
|
return (
|
|
<Modal className={baseClass} width="large" title={title} onExit={onDone}>
|
|
<ModalContent
|
|
data={data}
|
|
isLoading={isLoading}
|
|
error={error}
|
|
contentBody={contentBody}
|
|
/>
|
|
<ModalFooter primaryButtons={<Button onClick={onDone}>Close</Button>} />
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export default CommandResultsModal;
|