mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
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  - [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:
parent
3bd3d9bd48
commit
166e5ed663
46 changed files with 1067 additions and 549 deletions
1
changes/31226-batch-script-run-detail-page
Normal file
1
changes/31226-batch-script-run-detail-page
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Implement a new page for batch script run details
|
||||
|
|
@ -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"],
|
||||
],
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
.script-batch-status-table {
|
||||
tr:hover {
|
||||
.script-batch-host-count-cell__cancel-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./ScriptBatchStatusTable";
|
||||
|
|
@ -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’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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./ScriptBatchSummaryModal";
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./ScriptBatchHostsTable";
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./ScriptBatchDetailsPage";
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
border-radius: 4px;
|
||||
padding: 40px;
|
||||
gap: 8px;
|
||||
font-size: $small;
|
||||
p {
|
||||
margin: 0;
|
||||
@include help-text;
|
||||
|
|
|
|||
|
|
@ -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’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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./CancelScriptBatchModal";
|
||||
77
frontend/pages/ManageControlsPage/Scripts/helpers.tsx
Normal file
77
frontend/pages/ManageControlsPage/Scripts/helpers.tsx
Normal 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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -503,7 +503,10 @@ const DeviceUserPage = ({
|
|||
)}
|
||||
{isPremiumTier && (
|
||||
<Tab>
|
||||
<TabText count={failingPoliciesCount} isErrorCount>
|
||||
<TabText
|
||||
count={failingPoliciesCount}
|
||||
countVariant="alert"
|
||||
>
|
||||
Policies
|
||||
</TabText>
|
||||
</Tab>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ describe("HostSoftwareTable", () => {
|
|||
searchQuery: "",
|
||||
page: 0,
|
||||
pagePath: "/hosts/1/software",
|
||||
pathPrefix: "/hosts/1/software",
|
||||
vulnFilters: {},
|
||||
onAddFiltersClick: noop,
|
||||
onShowInventoryVersions: noop,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue