diff --git a/changes/31226-batch-script-run-detail-page b/changes/31226-batch-script-run-detail-page new file mode 100644 index 0000000000..823f0896ac --- /dev/null +++ b/changes/31226-batch-script-run-detail-page @@ -0,0 +1 @@ +- Implement a new page for batch script run details diff --git a/frontend/__mocks__/scriptMock.ts b/frontend/__mocks__/scriptMock.ts index 8bdbf6afde..ac62702a7f 100644 --- a/frontend/__mocks__/scriptMock.ts +++ b/frontend/__mocks__/scriptMock.ts @@ -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"], + ], + }; +}; diff --git a/frontend/components/TabNav/TabNav.stories.tsx b/frontend/components/TabNav/TabNav.stories.tsx index c86fc9ed64..b6120e930e 100644 --- a/frontend/components/TabNav/TabNav.stories.tsx +++ b/frontend/components/TabNav/TabNav.stories.tsx @@ -43,7 +43,7 @@ export const Default: Story = { { name: Tab with count, type: "type4" }, { name: ( - + Tab with error count ), diff --git a/frontend/components/TabText/TabText.tsx b/frontend/components/TabText/TabText.tsx index 10ac42b2d5..286a4aa1e1 100644 --- a/frontend/components/TabText/TabText.tsx +++ b/frontend/components/TabText/TabText.tsx @@ -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 = () => { diff --git a/frontend/components/TabText/_styles.scss b/frontend/components/TabText/_styles.scss index d28705f3ce..0c483d890c 100644 --- a/frontend/components/TabText/_styles.scss +++ b/frontend/components/TabText/_styles.scss @@ -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; + } } } diff --git a/frontend/components/buttons/ActionButtons/ActionButtons.tsx b/frontend/components/buttons/ActionButtons/ActionButtons.tsx index 3f757399d1..5c4d20b3ad 100644 --- a/frontend/components/buttons/ActionButtons/ActionButtons.tsx +++ b/frontend/components/buttons/ActionButtons/ActionButtons.tsx @@ -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 && } + {action.iconName && } )} @@ -101,7 +100,7 @@ const ActionButtons = ({ baseClass, actions }: IProps): JSX.Element => { ); diff --git a/frontend/interfaces/script.ts b/frontend/interfaces/script.ts index 24b4a5a118..33a0a01a39 100644 --- a/frontend/interfaces/script.ts +++ b/frontend/interfaces/script.ts @@ -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 + ); +}; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx index 5d2eb6605d..9b7898f5f6 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityFeed.tsx @@ -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 && ( - setScriptBatchExecutionDetails(null)} - router={router} - /> - )} ); }; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/ScriptBatchStatusTable.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/ScriptBatchStatusTable.tsx deleted file mode 100644 index 9236c78ba4..0000000000 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/ScriptBatchStatusTable.tsx +++ /dev/null @@ -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 ( - } - showMarkAllPages={false} - isAllPagesSelected={false} - manualSortBy - disableTableHeader - disablePagination - disableCount - hideFooter - /> - ); -}; - -export default ScriptBatchStatusTable; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/ScriptBatchStatusTableConfig.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/ScriptBatchStatusTableConfig.tsx deleted file mode 100644 index a177843716..0000000000 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/ScriptBatchStatusTableConfig.tsx +++ /dev/null @@ -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; -type IStatusCellProps = IStringCellProps; -type IHostCellProps = INumberCellProps; - -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 ( - - ); - }, - }, - { - Header: "Hosts", - accessor: "hosts", - disableSortBy: true, - Cell: ({ cell }: IHostCellProps) => { - return ( - - ); - }, - }, - ]; -}; - -export const generateTableData = ( - statusData: IScriptBatchSummaryResponseV1 -): IRowData[] => { - const tableData = STATUS_ORDER.map((status) => ({ - status, - hosts: statusData[status as keyof IScriptBatchSummaryResponseV1] as number, - })); - - return tableData; -}; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/_styles.scss b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/_styles.scss deleted file mode 100644 index 437ee0de6a..0000000000 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/_styles.scss +++ /dev/null @@ -1,7 +0,0 @@ -.script-batch-status-table { - tr:hover { - .script-batch-host-count-cell__cancel-button { - opacity: 1; - } - } -} diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/index.ts b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/index.ts deleted file mode 100644 index aa0fe85d9b..0000000000 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchStatusTable/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./ScriptBatchStatusTable"; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/ScriptBatchSummaryModal.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/ScriptBatchSummaryModal.tsx deleted file mode 100644 index 04a8f31ef8..0000000000 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/ScriptBatchSummaryModal.tsx +++ /dev/null @@ -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 ; - } - if (isError) { - return ; - } - return ( - - ); - }; - - 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 = ( - - Targeted - - ); - - 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 ( - - <> -
-

- This will cancel any pending script runs for{" "} - {details?.script_name || "this script"}. -

-

- If this script is currently running on a host, it will complete, - but results won’t appear in Fleet. -

-

You cannot undo this action.

-
- - -
-
- -
- ); - }; - - const parentModalClasses = classnames(baseClass, { - [`${baseClass}__hide-main`]: showCancelModal, - }); - - return ( - <> - -
-
- {activityCreatedAt && ( - - )} - -
- {renderTable()} -
- -
-
-
- {showCancelModal && renderCancelModal()} - - ); -}; - -export default ScriptBatchSummaryModal; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/_styles.scss b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/_styles.scss deleted file mode 100644 index 57b7b4bf30..0000000000 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/_styles.scss +++ /dev/null @@ -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; -} diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/index.ts b/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/index.ts deleted file mode 100644 index c2c26f6a96..0000000000 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/components/ScriptBatchSummaryModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./ScriptBatchSummaryModal"; diff --git a/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/ScriptBatchDetailsPage.tsx b/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/ScriptBatchDetailsPage.tsx new file mode 100644 index 0000000000..439f6de3bd --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/ScriptBatchDetailsPage.tsx @@ -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 = { + 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 ( +
+ No hosts with this status +

{EMPTY_STATE_DETAILS[status]}

+
+ ); +}; + +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(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 ( +
+ + + {hostStatusCount} host{hostStatusCount > 1 && "s"} + + + + +
+ ); + }; + + const renderContent = () => { + if (isLoading || !batchDetails) { + return ; + } + if (isError) { + return ; + } + 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 = ( + <> + + {targeted} hosts targeted ( + {Math.ceil(100 * ((ran + errored) / targeted))}% responded) + + {getWhen(batchDetails)} + + ); + + return ( + <> + + { + setShowBatchScriptDetails(true); + }, + }, + { + type: "secondary", + label: "Cancel", + onClick: () => { + setShowCancelModal(true); + }, + hideAction: status === "finished", + buttonVariant: "alert", + }, + ]} + /> + } + alignLeftHeaderVertically + greySubtitle + /> + + + + + Ran + + + + Errored + + + + + Pending + + + + + Incompatible + + + + + Canceled + + + + + {renderTabContent(getHostStatusAndCountByIndex(0))} + + + {renderTabContent(getHostStatusAndCountByIndex(1))} + + + {renderTabContent(getHostStatusAndCountByIndex(2))} + + + {renderTabContent(getHostStatusAndCountByIndex(3))} + + + {renderTabContent(getHostStatusAndCountByIndex(4))} + + + + + ); + }; + + return ( + <> + {renderContent()} + {showCancelModal && ( + { + setShowCancelModal(false); + }} + scriptName={batchDetails?.script_name} + isCanceling={isCanceling} + /> + )} + {showBatchScriptDetails && ( + { + setShowBatchScriptDetails(false); + }} + suppressSecondaryActions + /> + )} + {hostScriptExecutionIdForModal && ( + setHostScriptExecutionIdForModal(null)} + /> + )} + + ); +}; + +export default ScriptBatchDetailsPage; diff --git a/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/_styles.scss b/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/_styles.scss new file mode 100644 index 0000000000..72bfc98221 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/_styles.scss @@ -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; + } + } +} diff --git a/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/components/ScriptBatchHostsTable/ScriptBatchHostsTable.tsx b/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/components/ScriptBatchHostsTable/ScriptBatchHostsTable.tsx new file mode 100644 index 0000000000..72ff8910c6 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/components/ScriptBatchHostsTable/ScriptBatchHostsTable.tsx @@ -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 ; + } + + const columnConfigs = generateColumnConfigs(selectedHostStatus); + + return ( +
+ <>} // empty state handled by parent + disableMultiRowSelect + searchable={false} + onClickRow={handleRowClick} + onQueryChange={handleQueryChange} + /> +
+ ); +}; + +export default ScriptBatchHostsTable; diff --git a/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/components/ScriptBatchHostsTable/ScriptBatchHostsTableConfig.tsx b/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/components/ScriptBatchHostsTable/ScriptBatchHostsTableConfig.tsx new file mode 100644 index 0000000000..4b0752815d --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/components/ScriptBatchHostsTable/ScriptBatchHostsTableConfig.tsx @@ -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; +type ITableHeaderProps = IHeaderProps; +type ITableStringCellProps = IStringCellProps; +type ITimeCellProps = CellProps; + +const ScriptOutputCell = (cellProps: CellProps) => { + return ( + + + + + ); +}; + +const generateColumnConfigs = ( + hostStatus: ScriptBatchHostStatus +): IScriptBatchHostsTableConfig[] => { + let columns: IScriptBatchHostsTableConfig[] = [ + { + Header: (cellProps: ITableHeaderProps) => ( + + ), + accessor: "display_name", + Cell: (cellProps: ITableStringCellProps) => ( + + { + e.stopPropagation(); + }} + /> + {SCRIPT_BATCH_HOST_NOT_EXECUTED_STATUSES.includes(hostStatus) && ( + + )} + + ), + }, + ]; + + if (SCRIPT_BATCH_HOST_EXECUTED_STATUSES.includes(hostStatus)) { + columns = columns.concat([ + { + Header: (cellProps: ITableHeaderProps) => ( + + ), + accessor: "script_executed_at", + Cell: (cellProps: ITimeCellProps) => ( + + } + /> + ), + }, + { + Header: "Script output", + disableSortBy: true, + accessor: "script_output_preview", + Cell: (cellProps: any) => , + }, + ]); + } + + return columns; +}; + +export default generateColumnConfigs; diff --git a/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/components/ScriptBatchHostsTable/_styles.scss b/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/components/ScriptBatchHostsTable/_styles.scss new file mode 100644 index 0000000000..cbdf155f2f --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/components/ScriptBatchHostsTable/_styles.scss @@ -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; + } + } +} diff --git a/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/components/ScriptBatchHostsTable/index.ts b/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/components/ScriptBatchHostsTable/index.ts new file mode 100644 index 0000000000..763a0065f6 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/components/ScriptBatchHostsTable/index.ts @@ -0,0 +1 @@ +export { default } from "./ScriptBatchHostsTable"; diff --git a/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/index.ts b/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/index.ts new file mode 100644 index 0000000000..94ac2a4bf9 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/index.ts @@ -0,0 +1 @@ +export { default } from "./ScriptBatchDetailsPage"; diff --git a/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/ScriptBatchProgress.tests.tsx b/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/ScriptBatchProgress.tests.tsx index 73361f6327..b6b12c2d5b 100644 --- a/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/ScriptBatchProgress.tests.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/ScriptBatchProgress.tests.tsx @@ -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(); }); }); diff --git a/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/ScriptBatchProgress.tsx b/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/ScriptBatchProgress.tsx index 1783420a68..1c2e382672 100644 --- a/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/ScriptBatchProgress.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/ScriptBatchProgress.tsx @@ -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(null); const [batchCount, setBatchCount] = useState(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{" "} - - - ); - 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{" "} - - - ); - 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 ( - <> - - {canceled ? "Canceled" : "Completed"} - - - ); - default: - return null; - } - }; - const renderRow = (summary: IScriptBatchSummaryV2) => { const { script_name, @@ -326,16 +246,6 @@ const ScriptBatchProgress = ({ - {batchDetailsForSummary && ( - { - setShowBatchDetailsForSummary(null); - paginatedListRef.current?.reload(); - }} - router={router} - /> - )} ); }; diff --git a/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/_styles.scss b/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/_styles.scss index fc8e525c16..213986a1b0 100644 --- a/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/_styles.scss +++ b/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/_styles.scss @@ -40,6 +40,7 @@ border-radius: 4px; padding: 40px; gap: 8px; + font-size: $small; p { margin: 0; @include help-text; diff --git a/frontend/pages/ManageControlsPage/Scripts/components/CancelScriptBatchModal/CancelScriptBatchModal.tsx b/frontend/pages/ManageControlsPage/Scripts/components/CancelScriptBatchModal/CancelScriptBatchModal.tsx new file mode 100644 index 0000000000..86093cf510 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/components/CancelScriptBatchModal/CancelScriptBatchModal.tsx @@ -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 ( + + <> +
+

+ This will cancel any pending script runs for{" "} + {scriptName ? {scriptName} : "this batch"}. +

+

+ If this script is currently running on a host, it will complete, but + results won’t appear in Fleet. +

+

You cannot undo this action.

+
+ + +
+
+ +
+ ); +}; + +export default CancelScriptBatchModal; diff --git a/frontend/pages/ManageControlsPage/Scripts/components/CancelScriptBatchModal/index.ts b/frontend/pages/ManageControlsPage/Scripts/components/CancelScriptBatchModal/index.ts new file mode 100644 index 0000000000..2d8d54a47e --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/components/CancelScriptBatchModal/index.ts @@ -0,0 +1 @@ +export { default } from "./CancelScriptBatchModal"; diff --git a/frontend/pages/ManageControlsPage/Scripts/helpers.tsx b/frontend/pages/ManageControlsPage/Scripts/helpers.tsx new file mode 100644 index 0000000000..d305fb7e24 --- /dev/null +++ b/frontend/pages/ManageControlsPage/Scripts/helpers.tsx @@ -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 ( + <> + + Started{" "} + + + ); + 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 ( + <> + + Will start{" "} + + + ); + 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 ( + <> + + {canceled ? "Canceled" : "Completed"} + + + ); + default: + return null; + } +}; + +export default getWhen; diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx index c3ed678b94..949d443311 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx @@ -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, diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index de58533bc7..3598365134 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -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, diff --git a/frontend/pages/hosts/components/ScriptDetailsModal/ScriptDetailsModal.tsx b/frontend/pages/hosts/components/ScriptDetailsModal/ScriptDetailsModal.tsx index 10fe1ae803..de4b12b8c6 100644 --- a/frontend/pages/hosts/components/ScriptDetailsModal/ScriptDetailsModal.tsx +++ b/frontend/pages/hosts/components/ScriptDetailsModal/ScriptDetailsModal.tsx @@ -52,6 +52,7 @@ interface IScriptDetailsModalProps { refetchHostScripts?: ( options?: (RefetchOptions & RefetchQueryFilters) | undefined ) => Promise>; + 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) { diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index 0bb3d7d32f..d743d2c679 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -503,7 +503,10 @@ const DeviceUserPage = ({ )} {isPremiumTier && ( - + Policies diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 0a11efa029..298c2915b3 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -1130,7 +1130,7 @@ const HostDetailsPage = ({ // so we add a hidden pseudo element with the same text string return ( - + {navItem.name} diff --git a/frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx b/frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx index 16acff7cf9..10c68c5405 100644 --- a/frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx +++ b/frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx @@ -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(); diff --git a/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/HostPoliciesTableConfig.tsx b/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/HostPoliciesTableConfig.tsx index 3698b8f0f7..ee632760f2 100644 --- a/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/HostPoliciesTableConfig.tsx +++ b/frontend/pages/hosts/details/cards/Policies/HostPoliciesTable/HostPoliciesTableConfig.tsx @@ -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", diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx index c12df2d8f3..1570c80b1f 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx @@ -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} diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tests.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tests.tsx index eda6eaf699..30f0bde240 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tests.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tests.tsx @@ -22,7 +22,6 @@ describe("HostSoftwareTable", () => { searchQuery: "", page: 0, pagePath: "/hosts/1/software", - pathPrefix: "/hosts/1/software", vulnFilters: {}, onAddFiltersClick: noop, onShowInventoryVersions: noop, diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx index 183e3f0e47..b7b9cf44b3 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx @@ -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, diff --git a/frontend/pages/policies/PolicyPage/components/PolicyResults/PolicyResults.tsx b/frontend/pages/policies/PolicyPage/components/PolicyResults/PolicyResults.tsx index 76311d10c8..b3f7856d1e 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyResults/PolicyResults.tsx +++ b/frontend/pages/policies/PolicyPage/components/PolicyResults/PolicyResults.tsx @@ -255,7 +255,7 @@ const PolicyResults = ({ {NAV_TITLES.RESULTS} - + {NAV_TITLES.ERRORS} diff --git a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx index a640e2ba23..aa1635d5f9 100644 --- a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx +++ b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx @@ -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, diff --git a/frontend/pages/queries/edit/components/QueryResults/QueryResults.tsx b/frontend/pages/queries/edit/components/QueryResults/QueryResults.tsx index 6b8d33cefd..234a94409a 100644 --- a/frontend/pages/queries/edit/components/QueryResults/QueryResults.tsx +++ b/frontend/pages/queries/edit/components/QueryResults/QueryResults.tsx @@ -295,7 +295,7 @@ const QueryResults = ({ {NAV_TITLES.RESULTS} - + {NAV_TITLES.ERRORS} diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx index d0ebd0fd8f..a3cb77e548 100644 --- a/frontend/router/index.tsx +++ b/frontend/router/index.tsx @@ -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 = ( - - + + + + + diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 81dbf2e8f8..fb4922134b 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -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 diff --git a/frontend/services/entities/common.ts b/frontend/services/entities/common.ts index 30badf499b..e08514671c 100644 --- a/frontend/services/entities/common.ts +++ b/frontend/services/entities/common.ts @@ -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; +} diff --git a/frontend/services/entities/scripts.ts b/frontend/services/entities/scripts.ts index ffbfe7ec43..5f31122772 100644 --- a/frontend/services/entities/scripts.ts +++ b/frontend/services/entities/scripts.ts @@ -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 { + }: IScriptBatchSummaryParams): Promise { 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 { + return sendRequest( + "GET", + `${endpoints.SCRIPT_RUN_BATCH_SUMMARY_V2(batch_execution_id)}` + ); + }, + getRunScriptBatchSummaries( params: IScriptBatchSummariesParams ): Promise { const path = `${ @@ -281,4 +330,26 @@ export default { }?${buildQueryStringFromParams({ ...params })}`; return sendRequest("GET", path); }, + getScriptBatchHostResults( + params: IScriptBatchHostResultsParams + ): Promise { + 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); + }, }; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index cd3ff4f46d..d46891204e 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -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