fleet/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/ScriptBatchDetailsPage.tsx
jacobshandling 166e5ed663
UI: Batch script run detail page (#32333)
## 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


![ezgif-1438d4041f694f](https://github.com/user-attachments/assets/2d93127b-dea4-4ca6-abcc-7c888b2e0b93)


- [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>
2025-08-29 09:37:05 -06:00

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;