mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
## For #31226 New features: - Dynamic header for each possible state of a batch script run: Started, Scheduled, and Finished (corresponds to tabs at `/controls/scripts/progress` - Unique tabs for each possible status of hosts targeted by a batch script run: Ran, Errored, Pending, Incompatible, Canceled. - Within each tab, sortable, paginated host results with output preview and execution time. - View script/run details, cancel a batch, view manage hosts page filtered for the script batch run and a status. - Global script batch runs activities and and Scripts progress rows now navigate to this details page. Cleanups and improvements: - Expand tab count badge options using “alert”/“pending” variants across hosts, policies, and query results. - Misc cleanups and improvements  - [x] Changes file added for user-visible changes in `changes/`, - [x] Updated automated tests - new tests tracked for follow-up work - [x] QA'd all new/changed functionality manually --------- Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
import React, {
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
} from "react";
|
|
import { useQuery } from "react-query";
|
|
import { RouteComponentProps } from "react-router";
|
|
import { AxiosError } from "axios";
|
|
import { Tab, TabList, TabPanel, Tabs } from "react-tabs";
|
|
|
|
import { buildQueryStringFromParams } from "utilities/url";
|
|
|
|
import { NotificationContext } from "context/notification";
|
|
|
|
import scriptsAPI, {
|
|
IScriptBatchSummaryQueryKey,
|
|
IScriptBatchSummaryV2,
|
|
} from "services/entities/scripts";
|
|
|
|
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
|
|
|
|
import {
|
|
isValidScriptBatchHostStatus,
|
|
ScriptBatchHostStatus,
|
|
} from "interfaces/script";
|
|
|
|
import paths from "router/paths";
|
|
|
|
import ScriptDetailsModal from "pages/hosts/components/ScriptDetailsModal";
|
|
import RunScriptDetailsModal from "pages/DashboardPage/cards/ActivityFeed/components/RunScriptDetailsModal";
|
|
|
|
import BackLink from "components/BackLink";
|
|
import MainContent from "components/MainContent";
|
|
import SectionHeader from "components/SectionHeader";
|
|
import Spinner from "components/Spinner";
|
|
import ActionButtons from "components/buttons/ActionButtons/ActionButtons";
|
|
import DataError from "components/DataError";
|
|
import TabNav from "components/TabNav";
|
|
import TabText from "components/TabText";
|
|
import ViewAllHostsLink from "components/ViewAllHostsLink";
|
|
|
|
import getWhen from "../helpers";
|
|
import CancelScriptBatchModal from "../components/CancelScriptBatchModal";
|
|
import ScriptBatchHostsTable from "./components/ScriptBatchHostsTable";
|
|
|
|
const baseClass = "script-batch-details-page";
|
|
|
|
export const EMPTY_STATE_DETAILS: Record<ScriptBatchHostStatus, string> = {
|
|
ran: "Hosts with successful script results appear here.",
|
|
errored: "Hosts with error results appear here. ",
|
|
pending: "Compatible hosts that haven't run the script appear here.",
|
|
incompatible:
|
|
"Targeted hosts with incompatible operating systems appear here.",
|
|
canceled: "Hosts where this script run was cancelled appear here.",
|
|
};
|
|
|
|
const getEmptyState = (status: ScriptBatchHostStatus) => {
|
|
return (
|
|
<div className={`${baseClass}__empty`}>
|
|
<b>No hosts with this status</b>
|
|
<p>{EMPTY_STATE_DETAILS[status]}</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const HOSTS_STATUS_BY_INDEX: ScriptBatchHostStatus[] = [
|
|
"ran",
|
|
"errored",
|
|
"pending",
|
|
"incompatible",
|
|
"canceled",
|
|
];
|
|
|
|
interface IScriptBatchDetailsRouteParams {
|
|
batch_execution_id: string;
|
|
}
|
|
|
|
type IScriptBatchDetailsProps = RouteComponentProps<
|
|
undefined,
|
|
IScriptBatchDetailsRouteParams
|
|
>;
|
|
|
|
const ScriptBatchDetailsPage = ({
|
|
router,
|
|
routeParams,
|
|
location,
|
|
}: IScriptBatchDetailsProps) => {
|
|
const { batch_execution_id: batchExecutionId } = routeParams;
|
|
|
|
const hostStatusParam = location.query.status;
|
|
const pageParam = parseInt(location.query.page ?? "0", 10);
|
|
const orderKeyParam = location.query.order_key ?? "display_name";
|
|
const orderDirectionParam = location.query.order_direction ?? "asc";
|
|
|
|
const selectedHostStatus = hostStatusParam as ScriptBatchHostStatus;
|
|
|
|
const [showCancelModal, setShowCancelModal] = useState(false);
|
|
const [showBatchScriptDetails, setShowBatchScriptDetails] = useState(false);
|
|
const [
|
|
hostScriptExecutionIdForModal,
|
|
setHostScriptExecutionIdForModal,
|
|
] = useState<string | null>(null);
|
|
const [isCanceling, setIsCanceling] = useState(false);
|
|
|
|
const { renderFlash } = useContext(NotificationContext);
|
|
|
|
const { data: batchDetails, isLoading, isError } = useQuery<
|
|
IScriptBatchSummaryV2,
|
|
AxiosError,
|
|
IScriptBatchSummaryV2,
|
|
IScriptBatchSummaryQueryKey[]
|
|
>(
|
|
[{ scope: "script_batch_summary", batch_execution_id: batchExecutionId }],
|
|
({ queryKey }) => scriptsAPI.getRunScriptBatchSummaryV2(queryKey[0]),
|
|
{ ...DEFAULT_USE_QUERY_OPTIONS, enabled: !!batchExecutionId }
|
|
);
|
|
|
|
const pathToProgress = useMemo(() => {
|
|
const params = buildQueryStringFromParams({
|
|
status: batchDetails?.status,
|
|
team_id: batchDetails?.team_id,
|
|
});
|
|
|
|
return paths.CONTROLS_SCRIPTS_BATCH_PROGRESS + (params ? `?${params}` : "");
|
|
}, [batchDetails?.status, batchDetails?.team_id]);
|
|
|
|
const onCancelBatch = useCallback(async () => {
|
|
setIsCanceling(true);
|
|
|
|
try {
|
|
await scriptsAPI.cancelScriptBatch(batchExecutionId);
|
|
renderFlash("success", "Successfully canceled script.");
|
|
setShowCancelModal(false);
|
|
router.push(pathToProgress);
|
|
} catch (error) {
|
|
renderFlash("error", "Could not cancel script. Please try again.");
|
|
} finally {
|
|
setIsCanceling(false);
|
|
}
|
|
}, [batchExecutionId, pathToProgress, renderFlash, router]);
|
|
|
|
const handleTabChange = useCallback(
|
|
(index: number) => {
|
|
const newHostsStatus = HOSTS_STATUS_BY_INDEX[index];
|
|
|
|
const newParams = new URLSearchParams(location?.search);
|
|
newParams.set("status", newHostsStatus);
|
|
newParams.set("page", "0");
|
|
const newQuery = newParams.toString();
|
|
|
|
router.push(
|
|
paths
|
|
.CONTROLS_SCRIPTS_BATCH_DETAILS(batchExecutionId)
|
|
.concat(newQuery ? `?${newQuery}` : "")
|
|
);
|
|
},
|
|
[batchExecutionId, location?.search, router]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!isValidScriptBatchHostStatus(selectedHostStatus)) {
|
|
handleTabChange(0);
|
|
}
|
|
}, [handleTabChange, selectedHostStatus]);
|
|
|
|
const renderTabContent = ([hostStatus, hostStatusCount]: [
|
|
ScriptBatchHostStatus,
|
|
number
|
|
]) => {
|
|
if (hostStatusCount === 0) {
|
|
return getEmptyState(hostStatus);
|
|
}
|
|
return (
|
|
<div className={`${baseClass}__tab-content`}>
|
|
<span className={`${baseClass}__tab-content__header`}>
|
|
<b>
|
|
{hostStatusCount} host{hostStatusCount > 1 && "s"}
|
|
</b>
|
|
<ViewAllHostsLink
|
|
queryParams={{
|
|
script_batch_execution_status: selectedHostStatus, // refers to script batch host status, may update pending conv w Rachael
|
|
script_batch_execution_id: batchExecutionId,
|
|
}}
|
|
/>
|
|
</span>
|
|
<ScriptBatchHostsTable
|
|
batchExecutionId={batchExecutionId}
|
|
selectedHostStatus={hostStatus}
|
|
page={pageParam}
|
|
orderDirection={orderDirectionParam}
|
|
orderKey={orderKeyParam}
|
|
setHostScriptExecutionIdForModal={setHostScriptExecutionIdForModal}
|
|
router={router}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderContent = () => {
|
|
if (isLoading || !batchDetails) {
|
|
return <Spinner />;
|
|
}
|
|
if (isError) {
|
|
return <DataError description="Could not load script batch details." />;
|
|
}
|
|
const {
|
|
script_name,
|
|
status,
|
|
|
|
targeted_host_count: targeted,
|
|
ran_host_count: ran,
|
|
errored_host_count: errored,
|
|
pending_host_count: pending,
|
|
incompatible_host_count: incompatible,
|
|
canceled_host_count: canceled,
|
|
} = batchDetails || {};
|
|
|
|
const getHostStatusAndCountByIndex = (i: number) =>
|
|
([
|
|
["ran", ran],
|
|
["errored", errored],
|
|
["pending", pending],
|
|
["incompatible", incompatible],
|
|
["canceled", canceled],
|
|
] as [ScriptBatchHostStatus, number][])[i];
|
|
|
|
const subTitle = (
|
|
<>
|
|
<span>
|
|
<b>{targeted}</b> hosts targeted (
|
|
{Math.ceil(100 * ((ran + errored) / targeted))}% responded)
|
|
</span>
|
|
<span className="when">{getWhen(batchDetails)}</span>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<BackLink text="Back to script activity" path={pathToProgress} />
|
|
<SectionHeader
|
|
wrapperCustomClass={`${baseClass}__header`}
|
|
title={script_name}
|
|
subTitle={subTitle}
|
|
details={
|
|
<ActionButtons
|
|
baseClass={baseClass}
|
|
actions={[
|
|
{
|
|
type: "secondary",
|
|
label: "Show script",
|
|
buttonVariant: "text-icon",
|
|
iconName: "eye",
|
|
onClick: () => {
|
|
setShowBatchScriptDetails(true);
|
|
},
|
|
},
|
|
{
|
|
type: "secondary",
|
|
label: "Cancel",
|
|
onClick: () => {
|
|
setShowCancelModal(true);
|
|
},
|
|
hideAction: status === "finished",
|
|
buttonVariant: "alert",
|
|
},
|
|
]}
|
|
/>
|
|
}
|
|
alignLeftHeaderVertically
|
|
greySubtitle
|
|
/>
|
|
<TabNav>
|
|
<Tabs
|
|
selectedIndex={HOSTS_STATUS_BY_INDEX.indexOf(selectedHostStatus)}
|
|
onSelect={handleTabChange}
|
|
>
|
|
<TabList>
|
|
<Tab>
|
|
<TabText count={ran}>Ran</TabText>
|
|
</Tab>
|
|
<Tab>
|
|
<TabText count={errored} countVariant="alert">
|
|
Errored
|
|
</TabText>
|
|
</Tab>
|
|
<Tab>
|
|
<TabText count={pending} countVariant="pending">
|
|
Pending
|
|
</TabText>
|
|
</Tab>
|
|
<Tab>
|
|
<TabText count={incompatible} countVariant="pending">
|
|
Incompatible
|
|
</TabText>
|
|
</Tab>
|
|
<Tab>
|
|
<TabText count={canceled} countVariant="pending">
|
|
Canceled
|
|
</TabText>
|
|
</Tab>
|
|
</TabList>
|
|
<TabPanel>
|
|
{renderTabContent(getHostStatusAndCountByIndex(0))}
|
|
</TabPanel>
|
|
<TabPanel>
|
|
{renderTabContent(getHostStatusAndCountByIndex(1))}
|
|
</TabPanel>
|
|
<TabPanel>
|
|
{renderTabContent(getHostStatusAndCountByIndex(2))}
|
|
</TabPanel>
|
|
<TabPanel>
|
|
{renderTabContent(getHostStatusAndCountByIndex(3))}
|
|
</TabPanel>
|
|
<TabPanel>
|
|
{renderTabContent(getHostStatusAndCountByIndex(4))}
|
|
</TabPanel>
|
|
</Tabs>
|
|
</TabNav>
|
|
</>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<MainContent className={baseClass}>{renderContent()}</MainContent>
|
|
{showCancelModal && (
|
|
<CancelScriptBatchModal
|
|
onSubmit={onCancelBatch}
|
|
onExit={() => {
|
|
setShowCancelModal(false);
|
|
}}
|
|
scriptName={batchDetails?.script_name}
|
|
isCanceling={isCanceling}
|
|
/>
|
|
)}
|
|
{showBatchScriptDetails && (
|
|
<ScriptDetailsModal
|
|
selectedScriptId={batchDetails?.script_id}
|
|
onCancel={() => {
|
|
setShowBatchScriptDetails(false);
|
|
}}
|
|
suppressSecondaryActions
|
|
/>
|
|
)}
|
|
{hostScriptExecutionIdForModal && (
|
|
<RunScriptDetailsModal
|
|
scriptExecutionId={hostScriptExecutionIdForModal}
|
|
onCancel={() => setHostScriptExecutionIdForModal(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default ScriptBatchDetailsPage;
|