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>
This commit is contained in:
jacobshandling 2025-08-29 09:37:05 -06:00 committed by GitHub
parent 3bd3d9bd48
commit 166e5ed663
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 1067 additions and 549 deletions

View file

@ -0,0 +1 @@
- Implement a new page for batch script run details

View file

@ -1,8 +1,15 @@
import {
IScriptBatchHostResult,
IScriptBatchHostResultsResponse,
IScriptBatchSummaryV2,
IScriptResultResponse,
} from "services/entities/scripts";
import { IScript, IHostScript, ScriptBatchStatus } from "interfaces/script";
import {
IScript,
IHostScript,
ScriptBatchStatus,
ScriptBatchHostStatus,
} from "interfaces/script";
const DEFAULT_SCRIPT_MOCK: IScript = {
id: 1,
@ -76,3 +83,66 @@ export const createMockBatchScriptSummary = (
): IScriptBatchSummaryV2 => {
return { ...DEFAULT_SCRIPT_BATCH_SUMMARY_MOCK, ...overrides };
};
const SCRIPT_BATCH_HOST_RESULTS_BY_STATUS: Record<
ScriptBatchHostStatus,
IScriptBatchHostResult
> = {
ran: {
id: 1,
display_name: "Host 1",
script_status: "ran",
script_execution_id: "exec-1",
script_executed_at: "2023-07-10T18:31:08Z",
script_output_preview:
"Output from Host 1Output from Host 1Output from Host 1Output from Host 1Output from Host 1Output from Host 1Output from Host 1Output from Host 1Output from Host 1Output from Host 1Output from Host 1Output from Host 1Output from Host 1Output from Host 1Output from Host 1Output from Host 1Output from Host 1Output from Host 1Output from Host 1Output from Host 1Output from Host 1",
},
errored: {
id: 2,
display_name: "Host 2",
script_status: "errored",
script_execution_id: "exec-2",
script_executed_at: "2023-07-10T18:31:08Z",
script_output_preview: "Error output from Host 1",
},
pending: {
id: 3,
display_name: "Host 3",
script_status: "pending",
script_execution_id: null,
script_executed_at: null,
script_output_preview: null,
},
incompatible: {
id: 4,
display_name: "Host 4",
script_status: "incompatible",
script_execution_id: null,
script_executed_at: null,
script_output_preview: null,
},
canceled: {
id: 5,
display_name: "Host 5",
script_status: "canceled",
script_execution_id: null,
script_executed_at: null,
script_output_preview: null,
},
};
export const createMockScriptBatchHostResults = (
status?: ScriptBatchHostStatus
): IScriptBatchHostResultsResponse => {
return {
meta: {
has_next_results: false,
has_previous_results: false,
},
count: 2,
hosts: [
SCRIPT_BATCH_HOST_RESULTS_BY_STATUS[status || "ran"],
SCRIPT_BATCH_HOST_RESULTS_BY_STATUS[status || "ran"],
],
};
};

View file

@ -43,7 +43,7 @@ export const Default: Story = {
{ name: <TabText count={3}>Tab with count</TabText>, type: "type4" },
{
name: (
<TabText count={20} isErrorCount>
<TabText count={20} countVariant="alert">
Tab with error count
</TabText>
),

View file

@ -1,12 +1,12 @@
import React from "react";
import classnames from "classnames";
type TabCountVariant = "alert" | "pending";
interface ITabTextProps {
className?: string;
children: React.ReactNode;
count?: number;
/** Changes count badge from default purple to red */
isErrorCount?: boolean;
countVariant?: TabCountVariant;
}
/*
@ -19,12 +19,13 @@ const TabText = ({
className,
children,
count,
isErrorCount = false,
countVariant,
}: ITabTextProps): JSX.Element => {
const classNames = classnames(baseClass, className);
const countClassNames = classnames(`${baseClass}__count`, {
[`${baseClass}__count--error`]: isErrorCount,
[`${baseClass}__count__alert`]: countVariant === "alert",
[`${baseClass}__count__pending`]: countVariant === "pending",
});
const renderCount = () => {

View file

@ -16,8 +16,11 @@
font-weight: $bold;
font-size: $xx-small;
&--error {
&__alert {
background-color: $core-vibrant-red;
}
&__pending {
background-color: $ui-fleet-black-50;
}
}
}

View file

@ -13,11 +13,10 @@ import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
export interface IActionButtonProps {
type: "primary" | "secondary";
label: string;
buttonVariant?: ButtonVariant;
icon?: string;
iconSvg?: IconNames;
hideAction?: boolean;
onClick: () => void;
buttonVariant?: ButtonVariant;
iconName?: IconNames;
hideAction?: boolean;
gitOpsModeCompatible?: boolean;
}
@ -90,7 +89,7 @@ const ActionButtons = ({ baseClass, actions }: IProps): JSX.Element => {
>
<>
{action.label}
{action.iconSvg && <Icon name={action.iconSvg} />}
{action.iconName && <Icon name={action.iconName} />}
</>
</Button>
)}
@ -101,7 +100,7 @@ const ActionButtons = ({ baseClass, actions }: IProps): JSX.Element => {
<Button variant="text-icon" onClick={action.onClick}>
<>
{action.label}
{action.iconSvg && <Icon name={action.iconSvg} />}
{action.iconName && <Icon name={action.iconName} />}
</>
</Button>
);

View file

@ -41,3 +41,23 @@ export const isValidScriptBatchStatus = (
): status is ScriptBatchStatus => {
return SCRIPT_BATCH_STATUSES.includes((status ?? "") as ScriptBatchStatus);
};
export const SCRIPT_BATCH_HOST_EXECUTED_STATUSES = ["ran", "errored"];
export const SCRIPT_BATCH_HOST_NOT_EXECUTED_STATUSES = [
"pending",
"incompatible",
"canceled",
];
export const SCRIPT_BATCH_HOST_STATUSES = SCRIPT_BATCH_HOST_EXECUTED_STATUSES.concat(
SCRIPT_BATCH_HOST_NOT_EXECUTED_STATUSES
);
export type ScriptBatchHostStatus = typeof SCRIPT_BATCH_HOST_STATUSES[number];
export const isValidScriptBatchHostStatus = (
status: string | null | undefined
): status is ScriptBatchHostStatus => {
return SCRIPT_BATCH_HOST_STATUSES.includes(
(status ?? "") as ScriptBatchHostStatus
);
};

View file

@ -3,6 +3,8 @@ import { useQuery } from "react-query";
import { isEmpty } from "lodash";
import { InjectedRouter } from "react-router";
import paths from "router/paths";
import activitiesAPI, {
IActivitiesResponse,
} from "services/entities/activities";
@ -32,7 +34,6 @@ import ActivityAutomationDetailsModal from "./components/ActivityAutomationDetai
import RunScriptDetailsModal from "./components/RunScriptDetailsModal/RunScriptDetailsModal";
import SoftwareDetailsModal from "./components/LibrarySoftwareDetailsModal";
import VppDetailsModal from "./components/VPPDetailsModal";
import ScriptBatchSummaryModal from "./components/ScriptBatchSummaryModal";
const baseClass = "activity-feed";
interface IActvityCardProps {
@ -172,7 +173,11 @@ const ActivityFeed = ({
break;
case ActivityType.RanScriptBatch:
case ActivityType.CanceledScriptBatch:
setScriptBatchExecutionDetails({ ...details, created_at });
router.push(
paths.CONTROLS_SCRIPTS_BATCH_DETAILS(
details?.batch_execution_id || ""
)
);
break;
default:
break;
@ -296,13 +301,6 @@ const ActivityFeed = ({
onCancel={() => setVppDetails(null)}
/>
)}
{scriptBatchExecutionDetails && (
<ScriptBatchSummaryModal
scriptBatchExecutionDetails={scriptBatchExecutionDetails}
onCancel={() => setScriptBatchExecutionDetails(null)}
router={router}
/>
)}
</div>
);
};

View file

@ -1,50 +0,0 @@
import EmptyTable from "components/EmptyTable";
import TableContainer from "components/TableContainer";
import React, { useMemo } from "react";
import { IScriptBatchSummaryResponseV1 } from "services/entities/scripts";
import {
generateTableConfig,
generateTableData,
} from "./ScriptBatchStatusTableConfig";
const baseClass = "script-batch-status-table";
interface IScriptBatchStatusTableProps {
statusData: IScriptBatchSummaryResponseV1;
batchExecutionId: string;
onClickCancel: () => void;
}
const ScriptBatchStatusTable = ({
statusData,
batchExecutionId,
onClickCancel,
}: IScriptBatchStatusTableProps) => {
const columnConfigs = useMemo(() => {
return generateTableConfig(
batchExecutionId,
onClickCancel,
statusData.team_id
);
}, [batchExecutionId, onClickCancel, statusData.team_id]);
const tableData = generateTableData(statusData);
return (
<TableContainer
className={baseClass}
columnConfigs={columnConfigs}
data={tableData}
isLoading={false}
emptyComponent={() => <EmptyTable />}
showMarkAllPages={false}
isAllPagesSelected={false}
manualSortBy
disableTableHeader
disablePagination
disableCount
hideFooter
/>
);
};
export default ScriptBatchStatusTable;

View file

@ -1,99 +0,0 @@
import React from "react";
import { Column } from "react-table";
import StatusIndicatorWithIcon from "components/StatusIndicatorWithIcon";
import {
INumberCellProps,
IStringCellProps,
} from "interfaces/datatable_config";
import { IScriptBatchSummaryResponseV1 } from "services/entities/scripts";
import ScriptBatchHostCountCell from "../ScriptBatchHostCountCell/ScriptBatchHostCountCell";
type IStatus = "ran" | "pending" | "errored";
interface IRowData {
status: string;
hosts: number;
}
const STATUS_ORDER = ["ran", "pending", "errored", "canceled"];
export interface IStatusCellValue {
displayName: string;
statusName: IStatus;
value: IStatus;
}
const STATUS_DISPLAY_OPTIONS = {
ran: {
displayName: "Ran",
indicatorStatus: "success",
},
pending: {
displayName: "Pending",
indicatorStatus: "pendingPartial",
},
errored: {
displayName: "Error",
indicatorStatus: "error",
},
canceled: {
displayName: "Canceled",
indicatorStatus: "failure",
},
} as const;
type IColumnConfig = Column<IRowData>;
type IStatusCellProps = IStringCellProps<IRowData>;
type IHostCellProps = INumberCellProps<IRowData>;
export const generateTableConfig = (
batchExecutionId: string,
onClickCancel: () => void,
teamId?: number
): IColumnConfig[] => {
return [
{
Header: "Status",
disableSortBy: true,
accessor: "status",
Cell: ({ cell: { value } }: IStatusCellProps) => {
const statusOption =
STATUS_DISPLAY_OPTIONS[value as keyof typeof STATUS_DISPLAY_OPTIONS];
return (
<StatusIndicatorWithIcon
status={statusOption.indicatorStatus}
value={statusOption.displayName}
/>
);
},
},
{
Header: "Hosts",
accessor: "hosts",
disableSortBy: true,
Cell: ({ cell }: IHostCellProps) => {
return (
<ScriptBatchHostCountCell
count={cell.value}
status={cell.row.original.status}
batchExecutionId={batchExecutionId}
onClickCancel={onClickCancel}
teamId={teamId}
/>
);
},
},
];
};
export const generateTableData = (
statusData: IScriptBatchSummaryResponseV1
): IRowData[] => {
const tableData = STATUS_ORDER.map((status) => ({
status,
hosts: statusData[status as keyof IScriptBatchSummaryResponseV1] as number,
}));
return tableData;
};

View file

@ -1,7 +0,0 @@
.script-batch-status-table {
tr:hover {
.script-batch-host-count-cell__cancel-button {
opacity: 1;
}
}
}

View file

@ -1 +0,0 @@
export { default } from "./ScriptBatchStatusTable";

View file

@ -1,212 +0,0 @@
import React, { useCallback, useContext, useState } from "react";
import { InjectedRouter } from "react-router";
import classnames from "classnames";
import { IActivityDetails } from "interfaces/activity";
import { NotificationContext } from "context/notification";
import Modal from "components/Modal";
import DataSet from "components/DataSet";
import { dateAgo } from "utilities/date_format";
import TooltipWrapper from "components/TooltipWrapper";
import { useQuery } from "react-query";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import scriptsAPI, {
IScriptBatchSummaryQueryKey,
IScriptBatchSummaryResponseV1,
} from "services/entities/scripts";
import { AxiosError } from "axios";
import Spinner from "components/Spinner";
import DataError from "components/DataError";
import Button from "components/buttons/Button";
import ScriptBatchStatusTable from "../ScriptBatchStatusTable";
const baseClass = "script-batch-summary-modal";
export type IScriptBatchDetailsForSummary = Pick<
IActivityDetails,
"batch_execution_id" | "created_at" | "script_name" | "host_count"
>;
interface IScriptBatchSummaryModal {
scriptBatchExecutionDetails: IScriptBatchDetailsForSummary;
onCancel: () => void;
router: InjectedRouter;
}
const ScriptBatchSummaryModal = ({
scriptBatchExecutionDetails: details,
onCancel,
router,
}: IScriptBatchSummaryModal) => {
const [showCancelModal, setShowCancelModal] = useState(false);
const [isCanceling, setIsCanceling] = useState(false);
const { data: statusData, isLoading, isError } = useQuery<
IScriptBatchSummaryResponseV1,
AxiosError,
IScriptBatchSummaryResponseV1,
IScriptBatchSummaryQueryKey[]
>(
[
{
scope: "script_batch_summary",
batch_execution_id: details.batch_execution_id || "",
},
],
({ queryKey: [{ batch_execution_id }] }) =>
scriptsAPI.getRunScriptBatchSummary({ batch_execution_id }),
{
enabled: details.batch_execution_id !== undefined,
...DEFAULT_USE_QUERY_OPTIONS,
}
);
const toggleCancelModal = () => {
setShowCancelModal(!showCancelModal);
};
const renderTable = () => {
if (
!details.batch_execution_id ||
isLoading ||
!statusData ||
isCanceling
) {
return <Spinner />;
}
if (isError) {
return <DataError />;
}
return (
<ScriptBatchStatusTable
statusData={statusData}
batchExecutionId={details.batch_execution_id || ""}
onClickCancel={toggleCancelModal}
/>
);
};
let activityCreatedAt: Date | null = null;
if (details?.created_at) {
try {
activityCreatedAt = new Date(details?.created_at || "");
} catch (e) {
// invalid date string
activityCreatedAt = null;
}
}
const targetedTitle = (
<TooltipWrapper
tipContent="The number of hosts originally targeted,
including those where scripts were
incompatible or cancelled."
>
Targeted
</TooltipWrapper>
);
const { renderFlash } = useContext(NotificationContext);
const onConfirmCancel = useCallback(
async (batchExecutionId: string) => {
setIsCanceling(true);
try {
await scriptsAPI.cancelScriptBatch(batchExecutionId);
renderFlash("success", "Successfully canceled script.");
setShowCancelModal(false);
onCancel();
} catch (error) {
renderFlash("error", "Could not cancel script. Please try again.");
} finally {
setIsCanceling(false);
}
},
[renderFlash]
);
const renderCancelModal = () => {
const cancelBaseClass = "script-batch-cancel-modal";
if (!statusData) {
// the conditions for triggering the cancel modal mean this will never be the case. This is
// for the TS compiler
return null;
}
return (
<Modal
title="Cancel script?"
onExit={toggleCancelModal}
onEnter={toggleCancelModal}
className={cancelBaseClass}
>
<>
<div className={`${cancelBaseClass}__content`}>
<p>
This will cancel any pending script runs for{" "}
<b>{details?.script_name || "this script"}</b>.
</p>
<p>
If this script is currently running on a host, it will complete,
but results won&rsquo;t appear in Fleet.
</p>
<p>You cannot undo this action.</p>
<div className="modal-cta-wrap">
<Button
isLoading={isCanceling}
disabled={isCanceling}
onClick={() =>
onConfirmCancel(details.batch_execution_id || "")
}
variant="alert"
>
Cancel script
</Button>
<Button variant="inverse-alert" onClick={toggleCancelModal}>
Back
</Button>
</div>
</div>
</>
</Modal>
);
};
const parentModalClasses = classnames(baseClass, {
[`${baseClass}__hide-main`]: showCancelModal,
});
return (
<>
<Modal
// script_name will always be present at this point
title={details?.script_name || "Script Batch Summary"}
onExit={onCancel}
onEnter={onCancel}
className={parentModalClasses}
>
<div className={`${baseClass}__modal-content`}>
<div className="header">
{activityCreatedAt && (
<DataSet title="Ran" value={dateAgo(activityCreatedAt)} />
)}
<DataSet title={targetedTitle} value={details.host_count} />
</div>
{renderTable()}
<div className="modal-cta-wrap">
<Button onClick={onCancel}>Done</Button>
</div>
</div>
</Modal>
{showCancelModal && renderCancelModal()}
</>
);
};
export default ScriptBatchSummaryModal;

View file

@ -1,17 +0,0 @@
.script-batch-summary-modal {
&__modal-content {
.header {
display: flex;
gap: $pad-xxxlarge;
}
}
&__hide-main {
visibility: hidden;
}
}
// since this modal shows only while anotheris also showing, suppress background for one of them to
// prevent "double-darkening"
.modal__background:has(.script-batch-summary-modal.script-batch-summary-modal__hide-main) {
visibility: hidden;
}

View file

@ -1 +0,0 @@
export { default } from "./ScriptBatchSummaryModal";

View file

@ -0,0 +1,357 @@
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;

View file

@ -0,0 +1,47 @@
.script-batch-details-page {
display: flex;
flex-direction: column;
gap: 1rem;
&__header {
margin-bottom: 0;
h2 {
font-weight: $bold;
}
.section-header__sub-title {
display: flex;
align-items: center;
gap: $pad-large;
font-size: $x-small;
}
.when {
display: inline-flex;
align-items: center;
gap: $pad-xsmall;
}
}
&__tab-content {
&__header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: $x-small;
}
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
border: 1px solid $ui-fleet-black-10;
border-radius: 4px;
padding: 40px;
gap: 8px;
font-size: $small;
p {
margin: 0;
@include help-text;
}
}
}

View file

@ -0,0 +1,146 @@
import React, { useCallback } from "react";
import { useQuery } from "react-query";
import { InjectedRouter } from "react-router";
import { AxiosError } from "axios";
import PATHS from "router/paths";
import scriptsAPI, {
IScriptBatchHostResultsResponse,
IScriptBatchHostResultsQueryKey,
ScriptBatchHostsOrderKey,
} from "services/entities/scripts";
import { OrderDirection } from "services/entities/common";
import {
SCRIPT_BATCH_HOST_EXECUTED_STATUSES,
ScriptBatchHostStatus,
} from "interfaces/script";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import { getNextLocationPath } from "utilities/helpers";
import TableContainer from "components/TableContainer";
import DataError from "components/DataError";
import { ITableQueryData } from "components/TableContainer/TableContainer";
import generateColumnConfigs from "./ScriptBatchHostsTableConfig";
export const DEFAULT_SORT_DIRECTION = "asc";
export const DEFAULT_PAGE_SIZE = 20;
export const DEFAULT_SORT_COLUMN = "display_name";
const baseClass = "script-batch-hosts-table";
interface IScriptBatchHostsTableProps {
batchExecutionId: string;
selectedHostStatus: ScriptBatchHostStatus;
page: number;
orderDirection: OrderDirection;
orderKey: ScriptBatchHostsOrderKey;
setHostScriptExecutionIdForModal: (id: string) => void;
router: InjectedRouter;
}
const ScriptBatchHostsTable = ({
batchExecutionId,
selectedHostStatus,
page,
orderDirection,
orderKey,
setHostScriptExecutionIdForModal,
router,
}: IScriptBatchHostsTableProps) => {
const perPage = DEFAULT_PAGE_SIZE;
const { data: hostResults, isLoading, error } = useQuery<
IScriptBatchHostResultsResponse,
AxiosError,
IScriptBatchHostResultsResponse,
IScriptBatchHostResultsQueryKey[]
>(
[
{
scope: "script_batch_host_results",
batch_execution_id: batchExecutionId,
status: selectedHostStatus,
page,
per_page: perPage,
order_direction: orderDirection,
order_key: orderKey,
},
],
({ queryKey }) => scriptsAPI.getScriptBatchHostResults(queryKey[0]),
{
...DEFAULT_USE_QUERY_OPTIONS,
}
);
const handleRowClick = useCallback(
(row: any) => {
if (SCRIPT_BATCH_HOST_EXECUTED_STATUSES.includes(selectedHostStatus)) {
setHostScriptExecutionIdForModal(row.original.script_execution_id);
} else {
router.push(PATHS.HOST_DETAILS(row.original.id));
}
},
[router, selectedHostStatus, setHostScriptExecutionIdForModal]
);
const handleQueryChange = useCallback(
(newTableQuery: ITableQueryData) => {
const {
pageIndex: newPageIndex,
sortDirection: newOrderDirection,
sortHeader: newOrderKey,
} = newTableQuery;
const newQueryParams: { [key: string]: string | number | undefined } = {};
newQueryParams.status = selectedHostStatus;
newQueryParams.order_key = newOrderKey;
newQueryParams.order_direction = newOrderDirection;
newQueryParams.page = newPageIndex.toString();
if (newOrderKey !== orderKey || newOrderDirection !== orderDirection) {
newQueryParams.page = "0";
}
const path = getNextLocationPath({
pathPrefix: PATHS.CONTROLS_SCRIPTS_BATCH_DETAILS(batchExecutionId),
queryParams: newQueryParams,
});
router.push(path);
},
[selectedHostStatus, orderKey, orderDirection, batchExecutionId, router]
);
if (error) {
return <DataError description="Could not load host results." />;
}
const columnConfigs = generateColumnConfigs(selectedHostStatus);
return (
<div className={baseClass}>
<TableContainer
columnConfigs={columnConfigs}
data={hostResults?.hosts ?? []}
isLoading={isLoading}
defaultSortHeader={orderKey || DEFAULT_SORT_COLUMN}
defaultSortDirection={orderDirection || DEFAULT_SORT_DIRECTION}
pageIndex={page}
pageSize={perPage}
showMarkAllPages={false}
isAllPagesSelected={false}
manualSortBy
disableTableHeader
emptyComponent={() => <></>} // empty state handled by parent
disableMultiRowSelect
searchable={false}
onClickRow={handleRowClick}
onQueryChange={handleQueryChange}
/>
</div>
);
};
export default ScriptBatchHostsTable;

View file

@ -0,0 +1,110 @@
import React from "react";
import PATHS from "router/paths";
import {
SCRIPT_BATCH_HOST_EXECUTED_STATUSES,
SCRIPT_BATCH_HOST_NOT_EXECUTED_STATUSES,
ScriptBatchHostStatus,
} from "interfaces/script";
import { IScriptBatchHostResult } from "services/entities/scripts";
import { IHeaderProps, IStringCellProps } from "interfaces/datatable_config";
import TextCell from "components/TableContainer/DataTable/TextCell";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
import { HumanTimeDiffWithDateTip } from "components/HumanTimeDiffWithDateTip";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import { CellProps, Column } from "react-table";
import TooltipTruncatedText from "components/TooltipTruncatedText";
import LinkCell from "components/TableContainer/DataTable/LinkCell";
type IScriptBatchHostsTableConfig = Column<IScriptBatchHostResult>;
type ITableHeaderProps = IHeaderProps<IScriptBatchHostResult>;
type ITableStringCellProps = IStringCellProps<IScriptBatchHostResult>;
type ITimeCellProps = CellProps<IScriptBatchHostResult>;
const ScriptOutputCell = (cellProps: CellProps<IScriptBatchHostResult>) => {
return (
<span className="script-output-cell">
<TooltipTruncatedText
value={cellProps.row.original.script_output_preview}
/>
<ViewAllHostsLink
customText="View script details"
rowHover
noLink
responsive
/>
</span>
);
};
const generateColumnConfigs = (
hostStatus: ScriptBatchHostStatus
): IScriptBatchHostsTableConfig[] => {
let columns: IScriptBatchHostsTableConfig[] = [
{
Header: (cellProps: ITableHeaderProps) => (
<HeaderCell
value="Host name"
isSortedDesc={cellProps.column.isSortedDesc}
/>
),
accessor: "display_name",
Cell: (cellProps: ITableStringCellProps) => (
<span className="host-name-cell">
<LinkCell
value={cellProps.row.original.display_name}
path={PATHS.HOST_DETAILS(cellProps.row.original.id)}
customOnClick={(e) => {
e.stopPropagation();
}}
/>
{SCRIPT_BATCH_HOST_NOT_EXECUTED_STATUSES.includes(hostStatus) && (
<ViewAllHostsLink
customText="View host details"
rowHover
noLink
responsive
/>
)}
</span>
),
},
];
if (SCRIPT_BATCH_HOST_EXECUTED_STATUSES.includes(hostStatus)) {
columns = columns.concat([
{
Header: (cellProps: ITableHeaderProps) => (
<HeaderCell
value="Time"
disableSortBy={false}
isSortedDesc={cellProps.column.isSortedDesc}
/>
),
accessor: "script_executed_at",
Cell: (cellProps: ITimeCellProps) => (
<TextCell
value={
<HumanTimeDiffWithDateTip
timeString={cellProps.row.original.script_executed_at ?? ""}
/>
}
/>
),
},
{
Header: "Script output",
disableSortBy: true,
accessor: "script_output_preview",
Cell: (cellProps: any) => <ScriptOutputCell {...cellProps} />,
},
]);
}
return columns;
};
export default generateColumnConfigs;

View file

@ -0,0 +1,16 @@
.script-batch-hosts-table {
.host-name-cell {
display: flex;
align-items: center;
justify-content: space-between;
}
.script-output-cell {
display: flex;
align-items: center;
justify-content: space-between;
.tooltip-truncated-text {
max-width: 78%;
white-space: nowrap;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./ScriptBatchHostsTable";

View file

@ -0,0 +1 @@
export { default } from "./ScriptBatchDetailsPage";

View file

@ -93,9 +93,6 @@ const teamBatchSummariesHandler = http.get(
}
return HttpResponse.json({});
// TODO if status param === "scheduled"
// TODO if status param === "finished"
}
);
@ -200,7 +197,7 @@ describe("ScriptBatchProgress", () => {
await waitFor(() => {
expect(screen.getByText("Test Script 1")).toBeInTheDocument();
expect(screen.getByText(/Scheduled to start/)).toBeInTheDocument();
expect(screen.getByText(/Will start/)).toBeInTheDocument();
expect(screen.getByText(/in over \d+ years/)).toBeInTheDocument();
});
});

View file

@ -8,8 +8,6 @@ import scriptsAPI, { IScriptBatchSummaryV2 } from "services/entities/scripts";
import { isValidScriptBatchStatus, ScriptBatchStatus } from "interfaces/script";
import { isDateTimePast } from "utilities/helpers";
import { COLORS } from "styles/var/colors";
import Spinner from "components/Spinner";
@ -18,13 +16,10 @@ import SectionHeader from "components/SectionHeader";
import TabNav from "components/TabNav";
import TabText from "components/TabText";
import PaginatedList, { IPaginatedListHandle } from "components/PaginatedList";
import { HumanTimeDiffWithFleetLaunchCutoff } from "components/HumanTimeDiffWithDateTip";
import Icon from "components/Icon/Icon";
import ScriptBatchSummaryModal from "pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal";
import { IScriptBatchDetailsForSummary } from "pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/ScriptBatchSummaryModal";
import { IScriptsCommonProps } from "../../ScriptsNavItems";
import getWhen from "../../helpers";
const baseClass = "script-batch-progress";
@ -58,10 +53,6 @@ const ScriptBatchProgress = ({
router,
teamId,
}: IScriptBatchProgressProps) => {
const [
batchDetailsForSummary,
setShowBatchDetailsForSummary,
] = useState<IScriptBatchDetailsForSummary | null>(null);
const [batchCount, setBatchCount] = useState<number | null>(null);
const [updating, setUpdating] = useState(false);
@ -128,82 +119,11 @@ const ScriptBatchProgress = ({
);
const onClickRow = (r: IScriptBatchSummaryV2) => {
setShowBatchDetailsForSummary({
batch_execution_id: r.batch_execution_id,
script_name: r.script_name,
host_count: r.targeted_host_count,
});
router.push(PATHS.CONTROLS_SCRIPTS_BATCH_DETAILS(r.batch_execution_id));
// return satisfies caller expectations, not used in this case
return r;
};
const getWhen = (summary: IScriptBatchSummaryV2) => {
const {
batch_execution_id: id,
not_before,
started_at,
finished_at,
canceled,
} = summary;
switch (summary.status) {
case "started":
if (!started_at || !isDateTimePast(started_at)) {
console.warn(
`Batch run with execution id ${id} is marked as 'started' but has no past 'started_at'`
);
return null;
}
return (
<>
Started{" "}
<HumanTimeDiffWithFleetLaunchCutoff
timeString={started_at}
tooltipPosition="right"
/>
</>
);
case "scheduled":
if (!not_before || isDateTimePast(not_before)) {
console.warn(
`Batch run with execution id ${id} is marked as 'scheduled' but has no future scheduled start time`
);
return null;
}
return (
<>
Scheduled to start{" "}
<HumanTimeDiffWithFleetLaunchCutoff
timeString={not_before}
tooltipPosition="right"
/>
</>
);
case "finished":
if (!finished_at || !isDateTimePast(finished_at)) {
console.warn(
`Batch run with execution id ${id} is marked as 'finished' but has no past 'finished_at' data`
);
return null;
}
return (
<>
<Icon
name={canceled ? "close-filled" : "success"}
color="ui-fleet-black-50"
size="small"
/>
{canceled ? "Canceled" : "Completed"}
<HumanTimeDiffWithFleetLaunchCutoff
timeString={finished_at}
tooltipPosition="right"
/>
</>
);
default:
return null;
}
};
const renderRow = (summary: IScriptBatchSummaryV2) => {
const {
script_name,
@ -326,16 +246,6 @@ const ScriptBatchProgress = ({
</Tabs>
</TabNav>
</div>
{batchDetailsForSummary && (
<ScriptBatchSummaryModal
scriptBatchExecutionDetails={{ ...batchDetailsForSummary }}
onCancel={() => {
setShowBatchDetailsForSummary(null);
paginatedListRef.current?.reload();
}}
router={router}
/>
)}
</>
);
};

View file

@ -40,6 +40,7 @@
border-radius: 4px;
padding: 40px;
gap: 8px;
font-size: $small;
p {
margin: 0;
@include help-text;

View file

@ -0,0 +1,58 @@
import React from "react";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
const baseClass = "cancel-script-batch-modal";
interface ICancelScriptBatchModal {
onExit: () => void;
onSubmit: () => void;
scriptName?: string;
isCanceling: boolean;
}
const CancelScriptBatchModal = ({
onSubmit,
onExit,
scriptName,
isCanceling,
}: ICancelScriptBatchModal) => {
return (
<Modal
title="Cancel script?"
onExit={onExit}
onEnter={onSubmit}
className={baseClass}
>
<>
<div className={`${baseClass}__content`}>
<p>
This will cancel any pending script runs for{" "}
{scriptName ? <b>{scriptName}</b> : "this batch"}.
</p>
<p>
If this script is currently running on a host, it will complete, but
results won&rsquo;t appear in Fleet.
</p>
<p>You cannot undo this action.</p>
<div className="modal-cta-wrap">
<Button
isLoading={isCanceling}
disabled={isCanceling}
onClick={onSubmit}
variant="alert"
>
Cancel script
</Button>
<Button variant="inverse-alert" onClick={onExit}>
Back
</Button>
</div>
</div>
</>
</Modal>
);
};
export default CancelScriptBatchModal;

View file

@ -0,0 +1 @@
export { default } from "./CancelScriptBatchModal";

View file

@ -0,0 +1,77 @@
import { HumanTimeDiffWithFleetLaunchCutoff } from "components/HumanTimeDiffWithDateTip";
import Icon from "components/Icon";
import React from "react";
import { IScriptBatchSummaryV2 } from "services/entities/scripts";
import { isDateTimePast } from "utilities/helpers";
const getWhen = (summary: IScriptBatchSummaryV2) => {
const {
batch_execution_id: id,
not_before,
started_at,
finished_at,
canceled,
} = summary;
switch (summary.status) {
case "started":
if (!started_at || !isDateTimePast(started_at)) {
console.warn(
`Batch run with execution id ${id} is marked as 'started' but has no past 'started_at'`
);
return null;
}
return (
<>
<Icon name="pending-outline" color="ui-fleet-black-50" size="small" />
Started{" "}
<HumanTimeDiffWithFleetLaunchCutoff
timeString={started_at}
tooltipPosition="right"
/>
</>
);
case "scheduled":
if (!not_before || isDateTimePast(not_before)) {
console.warn(
`Batch run with execution id ${id} is marked as 'scheduled' but has no future scheduled start time`
);
return null;
}
return (
<>
<Icon name="clock" color="ui-fleet-black-50" size="small" />
Will start{" "}
<HumanTimeDiffWithFleetLaunchCutoff
timeString={not_before}
tooltipPosition="right"
/>
</>
);
case "finished":
if (!finished_at || !isDateTimePast(finished_at)) {
console.warn(
`Batch run with execution id ${id} is marked as 'finished' but has no past 'finished_at' data`
);
return null;
}
return (
<>
<Icon
name={canceled ? "close-filled" : "success"}
color="ui-fleet-black-50"
size="small"
/>
{canceled ? "Canceled" : "Completed"}
<HumanTimeDiffWithFleetLaunchCutoff
timeString={finished_at}
tooltipPosition="right"
/>
</>
);
default:
return null;
}
};
export default getWhen;

View file

@ -427,7 +427,7 @@ const TeamDetailsWrapper = ({
type: "secondary",
label: "Manage enroll secrets",
buttonVariant: "text-icon",
iconSvg: "eye",
iconName: "eye",
onClick: toggleManageEnrollSecretsModal,
gitOpsModeCompatible: true,
},
@ -435,7 +435,7 @@ const TeamDetailsWrapper = ({
type: "secondary",
label: "Rename team",
buttonVariant: "text-icon",
iconSvg: "pencil",
iconName: "pencil",
onClick: toggleRenameTeamModal,
gitOpsModeCompatible: true,
},
@ -443,7 +443,7 @@ const TeamDetailsWrapper = ({
type: "secondary",
label: "Delete team",
buttonVariant: "text-icon",
iconSvg: "trash",
iconName: "trash",
hideAction: !isGlobalAdmin,
onClick: toggleDeleteTeamModal,
gitOpsModeCompatible: true,

View file

@ -15,7 +15,7 @@ import FileSaver from "file-saver";
import scriptsAPI, {
IScriptBatchSummaryQueryKey,
IScriptBatchSummaryResponseV1,
IScriptBatchSummaryV1,
ScriptBatchHostCountV1,
} from "services/entities/scripts";
import enrollSecretsAPI from "services/entities/enroll_secret";
@ -306,6 +306,8 @@ const ManageHostsPage = ({
const configProfileUUID = queryParams?.profile_uuid;
const scriptBatchExecutionId =
queryParams?.[HOSTS_QUERY_PARAMS.SCRIPT_BATCH_EXECUTION_ID];
/** This actually represents HOST statuses, not the status of a batch script execution overall.
* Consider renaming this to `scriptBatchHostStatus` */
const scriptBatchExecutionStatus: ScriptBatchHostCountV1 =
queryParams?.[HOSTS_QUERY_PARAMS.SCRIPT_BATCH_EXECUTION_STATUS] ??
(scriptBatchExecutionId ? "ran" : undefined);
@ -472,9 +474,9 @@ const ManageHostsPage = ({
isLoading: isLoadingScriptBatchSummary,
isError: isErrorScriptBatchSummary,
} = useQuery<
IScriptBatchSummaryResponseV1,
IScriptBatchSummaryV1,
Error,
IScriptBatchSummaryResponseV1,
IScriptBatchSummaryV1,
IScriptBatchSummaryQueryKey[]
>(
[
@ -484,7 +486,7 @@ const ManageHostsPage = ({
},
],
({ queryKey: [{ batch_execution_id }] }) =>
scriptsAPI.getRunScriptBatchSummary({ batch_execution_id }),
scriptsAPI.getRunScriptBatchSummaryV1({ batch_execution_id }),
{
enabled: !!scriptBatchExecutionId && isRouteOk,
...DEFAULT_USE_QUERY_OPTIONS,

View file

@ -52,6 +52,7 @@ interface IScriptDetailsModalProps {
refetchHostScripts?: <TPageData>(
options?: (RefetchOptions & RefetchQueryFilters<TPageData>) | undefined
) => Promise<QueryObserverResult<IHostScriptsResponse, IApiError>>;
selectedScriptId?: number;
selectedScriptDetails?: PartialOrFullHostScript | IPaginatedListScript;
selectedScriptContent?: string;
isLoadingScriptContent?: boolean;
@ -72,6 +73,7 @@ const ScriptDetailsModal = ({
hostId,
hostTeamId,
refetchHostScripts,
selectedScriptId,
selectedScriptDetails,
selectedScriptContent,
isLoadingScriptContent,
@ -98,7 +100,9 @@ const ScriptDetailsModal = ({
// handle multiple possibilities for `selectedScriptDetails`
let scriptId: number | null = null;
if (selectedScriptDetails) {
if (selectedScriptId) {
scriptId = selectedScriptId;
} else if (selectedScriptDetails) {
if ("script_id" in selectedScriptDetails) {
scriptId = selectedScriptDetails.script_id;
} else if ("id" in selectedScriptDetails) {

View file

@ -503,7 +503,10 @@ const DeviceUserPage = ({
)}
{isPremiumTier && (
<Tab>
<TabText count={failingPoliciesCount} isErrorCount>
<TabText
count={failingPoliciesCount}
countVariant="alert"
>
Policies
</TabText>
</Tab>

View file

@ -1130,7 +1130,7 @@ const HostDetailsPage = ({
// so we add a hidden pseudo element with the same text string
return (
<Tab key={navItem.title}>
<TabText count={navItem.count} isErrorCount>
<TabText count={navItem.count} countVariant="alert">
{navItem.name}
</TabText>
</Tab>

View file

@ -42,10 +42,7 @@ const Policies = ({
router,
currentTeamId,
}: IPoliciesProps): JSX.Element => {
const tableHeaders = generatePolicyTableHeaders(
togglePolicyDetailsModal,
currentTeamId
);
const tableHeaders = generatePolicyTableHeaders(currentTeamId);
if (deviceUser) {
// Remove view all hosts link
tableHeaders.pop();

View file

@ -65,10 +65,7 @@ const POLICY_STATUS_TO_INDICATOR_PARAMS: Record<
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
const generatePolicyTableHeaders = (
togglePolicyDetails: (policy: IHostPolicy, teamId?: number) => void,
currentTeamId?: number
): IDataColumn[] => {
const generatePolicyTableHeaders = (currentTeamId?: number): IDataColumn[] => {
return [
{
title: "Name",

View file

@ -306,7 +306,6 @@ const HostSoftware = ({
max_cvss_score: queryParams.max_cvss_score,
})}
onAddFiltersClick={toggleSoftwareFiltersModal}
pathPrefix={pathname}
// for my device software details modal toggling
isMyDevicePage={isMyDevicePage}
onShowInventoryVersions={onShowInventoryVersions}

View file

@ -22,7 +22,6 @@ describe("HostSoftwareTable", () => {
searchQuery: "",
page: 0,
pagePath: "/hosts/1/software",
pathPrefix: "/hosts/1/software",
vulnFilters: {},
onAddFiltersClick: noop,
onShowInventoryVersions: noop,

View file

@ -74,8 +74,6 @@ interface IHostSoftwareTableProps {
searchQuery: string;
page: number;
pagePath: string;
routeTemplate?: string;
pathPrefix: string;
vulnFilters: ISoftwareVulnFiltersParams;
onAddFiltersClick: () => void;
isMyDevicePage?: boolean;
@ -93,8 +91,6 @@ const HostSoftwareTable = ({
searchQuery,
page,
pagePath,
routeTemplate,
pathPrefix,
vulnFilters,
onAddFiltersClick,
isMyDevicePage,

View file

@ -255,7 +255,7 @@ const PolicyResults = ({
<TabText>{NAV_TITLES.RESULTS}</TabText>
</Tab>
<Tab disabled={!errors?.length}>
<TabText count={errors?.length} isErrorCount>
<TabText count={errors?.length} countVariant="alert">
{NAV_TITLES.ERRORS}
</TabText>
</Tab>

View file

@ -5,8 +5,6 @@ import React from "react";
import { formatDistanceToNow } from "date-fns";
import PATHS from "router/paths";
import { Tooltip as ReactTooltip5 } from "react-tooltip-5";
import { secondsToDhms } from "utilities/helpers";
import {
isGlobalAdmin,

View file

@ -295,7 +295,7 @@ const QueryResults = ({
<TabList>
<Tab className={firstTabClass}>{NAV_TITLES.RESULTS}</Tab>
<Tab disabled={!errors?.length}>
<TabText count={errors?.length} isErrorCount>
<TabText count={errors?.length} countVariant="alert">
{NAV_TITLES.ERRORS}
</TabText>
</Tab>

View file

@ -83,6 +83,7 @@ import SoftwareFleetMaintained from "pages/SoftwarePage/SoftwareAddPage/Software
import SoftwareCustomPackage from "pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage";
import SoftwareAppStore from "pages/SoftwarePage/SoftwareAddPage/SoftwareAppStoreVpp";
import FleetMaintainedAppDetailsPage from "pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage";
import ScriptBatchDetailsPage from "pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage";
import PATHS from "router/paths";
@ -286,8 +287,10 @@ const routes = (
<Route path="os-settings" component={OSSettings} />
<Route path="os-settings/:section" component={OSSettings} />
<Route path="setup-experience" component={SetupExperience} />
<Route path="scripts" component={Scripts} />
<Route path="scripts/:section" component={Scripts} />
<Route path="scripts">
<IndexRedirect to="library" />
<Route path=":section" component={Scripts} />
</Route>
<Route path="variables" component={Secrets} />
<Route
path="setup-experience/:section"
@ -295,6 +298,10 @@ const routes = (
/>
</Route>
</Route>
<Route
path="controls/scripts/progress/:batch_execution_id"
component={ScriptBatchDetailsPage}
/>
</Route>
<Route path="software">
<IndexRedirect to="titles" />

View file

@ -21,6 +21,8 @@ export default {
CONTROLS_SCRIPTS: `${URL_PREFIX}/controls/scripts`,
CONTROLS_SCRIPTS_LIBRARY: `${URL_PREFIX}/controls/scripts/library`,
CONTROLS_SCRIPTS_BATCH_PROGRESS: `${URL_PREFIX}/controls/scripts/progress`,
CONTROLS_SCRIPTS_BATCH_DETAILS: (batchExecutionId: string) =>
`${URL_PREFIX}/controls/scripts/progress/${batchExecutionId}`,
CONTROLS_VARIABLES: `${URL_PREFIX}/controls/variables`,
// Dashboard pages

View file

@ -1,5 +1,17 @@
// TODO - apply broadly
export interface PaginationMeta {
interface ListEntitiesResponsePaginationCommon {
has_next_results: boolean;
has_previous_results: boolean;
}
export interface ListEntitiesResponseCommon {
meta: ListEntitiesResponsePaginationCommon;
count: number;
}
export type OrderDirection = "asc" | "desc";
export interface PaginationParams {
page: number;
per_page: number;
}

View file

@ -1,9 +1,23 @@
import { IHostScript, IScript, ScriptBatchStatus } from "interfaces/script";
import {
IHostScript,
IScript,
ScriptBatchHostStatus,
ScriptBatchStatus,
} from "interfaces/script";
import sendRequest from "services";
import {
createMockBatchScriptSummary,
createMockScriptBatchHostResults,
} from "__mocks__/scriptMock";
import endpoints from "utilities/endpoints";
import { buildQueryStringFromParams } from "utilities/url";
import { PaginationMeta } from "./common";
import {
ListEntitiesResponseCommon,
OrderDirection,
PaginationParams,
} from "./common";
/** Single script response from GET /script/:id */
export type IScriptResponse = IScript;
@ -132,8 +146,7 @@ export interface IScriptBatchHostCountsV1 {
export type ScriptBatchHostCountV1 = keyof IScriptBatchHostCountsV1;
// 200 successful response
export interface IScriptBatchSummaryResponseV1
extends IScriptBatchHostCountsV1 {
export interface IScriptBatchSummaryV1 extends IScriptBatchHostCountsV1 {
team_id: number;
script_name: string;
created_at: string;
@ -145,8 +158,8 @@ export interface IScriptBatchSummaryResponseV1
export interface IScriptBatchHostCountsV2 {
targeted_host_count: number;
ran_host_count: number;
pending_host_count: number;
errored_host_count: number;
pending_host_count: number;
incompatible_host_count: number;
canceled_host_count: number;
}
@ -168,6 +181,7 @@ export interface IScriptBatchSummaryV2 extends IScriptBatchHostCountsV2 {
/** ISO 8601 date-time string. If present, this script has completed running. */
finished_at: string | null;
}
export interface IScriptBatchSummariesParams {
team_id: number;
status: ScriptBatchStatus;
@ -179,10 +193,37 @@ export interface IScriptBatchSummariesQueryKey
scope: "script_batch_summaries";
}
export interface IScriptBatchSummariesResponse {
export interface IScriptBatchSummariesResponse
extends ListEntitiesResponseCommon {
batch_executions: IScriptBatchSummaryV2[] | null; // should not return `null`, but API currently does sometimes. Remove this option when it's fixed.
meta: PaginationMeta;
count: number;
}
export type ScriptBatchHostsOrderKey = "display_name" | "script_executed_at";
export interface IScriptBatchHostResultsParams extends PaginationParams {
batch_execution_id: string;
status: ScriptBatchHostStatus;
order_key: ScriptBatchHostsOrderKey;
order_direction: OrderDirection;
}
export interface IScriptBatchHostResultsQueryKey
extends IScriptBatchHostResultsParams {
scope: "script_batch_host_results";
}
export interface IScriptBatchHostResult {
id: number;
display_name: string;
script_status: ScriptBatchHostStatus;
script_execution_id: string | null; // if status === pending, this may be `null` or contain a value dependending on whether the script exectution is this hosts next scheduled activity on the server
// /** ISO 8601 date-time string. `null` if pending, cancelled, or incompatible. */
script_executed_at: string | null;
/** `null` if pending, cancelled, or incompatible. */
script_output_preview: string | null;
}
export interface IScriptBatchHostResultsResponse
extends ListEntitiesResponseCommon {
hosts: IScriptBatchHostResult[];
}
export default {
@ -265,15 +306,23 @@ export default {
return sendRequest("POST", SCRIPT_CANCEL_BATCH(batchExecutionId));
},
/** calls the deprecated endpoint */
getRunScriptBatchSummary({
getRunScriptBatchSummaryV1({
batch_execution_id,
}: IScriptBatchSummaryParams): Promise<IScriptBatchSummaryResponseV1> {
}: IScriptBatchSummaryParams): Promise<IScriptBatchSummaryV1> {
return sendRequest(
"GET",
`${endpoints.SCRIPT_RUN_BATCH_SUMMARY(batch_execution_id)}`
`${endpoints.SCRIPT_RUN_BATCH_SUMMARY_V1(batch_execution_id)}`
);
},
async getRunScriptBatchSummaries(
getRunScriptBatchSummaryV2({
batch_execution_id,
}: IScriptBatchSummaryParams): Promise<IScriptBatchSummaryV2> {
return sendRequest(
"GET",
`${endpoints.SCRIPT_RUN_BATCH_SUMMARY_V2(batch_execution_id)}`
);
},
getRunScriptBatchSummaries(
params: IScriptBatchSummariesParams
): Promise<IScriptBatchSummariesResponse> {
const path = `${
@ -281,4 +330,26 @@ export default {
}?${buildQueryStringFromParams({ ...params })}`;
return sendRequest("GET", path);
},
getScriptBatchHostResults(
params: IScriptBatchHostResultsParams
): Promise<IScriptBatchHostResultsResponse> {
const {
batch_execution_id,
status,
page,
per_page,
order_key,
order_direction,
} = params;
const path = `${endpoints.SCRIPT_BATCH_HOST_RESULTS(
batch_execution_id
)}?${buildQueryStringFromParams({
status,
page,
per_page,
order_key: order_key === "script_executed_at" ? "updated_at" : order_key, // map to server field name
order_direction,
})}`;
return sendRequest("GET", path);
},
};

View file

@ -276,9 +276,13 @@ export default {
SCRIPT_RUN_BATCH: `/${API_VERSION}/fleet/scripts/run/batch`,
SCRIPT_CANCEL_BATCH: (executionId: string) =>
`/${API_VERSION}/fleet/scripts/batch/${executionId}/cancel`,
SCRIPT_RUN_BATCH_SUMMARY: (id: string) =>
SCRIPT_RUN_BATCH_SUMMARY_V1: (id: string) =>
`/${API_VERSION}/fleet/scripts/batch/summary/${id}`,
SCRIPT_RUN_BATCH_SUMMARY_V2: (id: string) =>
`/${API_VERSION}/fleet/scripts/batch/${id}`,
SCRIPT_RUN_BATCH_SUMMARIES: `/${API_VERSION}/fleet/scripts/batch`,
SCRIPT_BATCH_HOST_RESULTS: (id: string) =>
`/${API_VERSION}/fleet/scripts/batch/${id}/host-results`,
COMMANDS_RESULTS: `/${API_VERSION}/fleet/commands/results`,
// idp endpoints