fleet/frontend/pages/ManageControlsPage/Scripts/cards/ScriptBatchProgress/ScriptBatchProgress.tsx
Scott Gress 4ce8a095c7
Reload view after canceling script, and update time validation (#32242)
for #32238

# Details

* After a script is canceled, refresh the current tab to show that the
canceled script is no longer there
* When scheduling a script, check the specified time against UTC, not
current timezone time of day.

# Checklist for submitter

## Testing

- [X] QA'd all new/changed functionality manually

For unreleased bug fixes in a release candidate, one of:

- [X] Confirmed that the fix is not expected to adversely impact load
test results
2025-08-25 09:15:12 -05:00

346 lines
9.9 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState } from "react";
import { useQueryClient } from "react-query";
import { Tab, TabList, TabPanel, Tabs } from "react-tabs";
import PATHS from "router/paths";
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";
import ProgressBar from "components/ProgressBar";
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 { ScriptsLocation } from "../../Scripts";
const baseClass = "script-batch-progress";
const STATUS_BY_INDEX: ScriptBatchStatus[] = [
"started",
"scheduled",
"finished",
];
export const EMPTY_STATE_DETAILS: Record<ScriptBatchStatus, string> = {
started: "When a script is run on multiple hosts, progress will appear here.",
scheduled:
"When a script is scheduled to run in the future, it will appear here.",
finished:
"When a batch script is completed or canceled, historical results will appear here.",
};
const getEmptyState = (status: ScriptBatchStatus) => {
return (
<div className={`${baseClass}__empty`}>
<b>No batch scripts {status} for this team</b>
<p>{EMPTY_STATE_DETAILS[status]}</p>
</div>
);
};
export type IScriptBatchProgressProps = IScriptsCommonProps & {
location?: ScriptsLocation;
};
const ScriptBatchProgress = ({
location,
router,
teamId,
}: IScriptBatchProgressProps) => {
const [
batchDetailsForSummary,
setShowBatchDetailsForSummary,
] = useState<IScriptBatchDetailsForSummary | null>(null);
const [batchCount, setBatchCount] = useState<number | null>(null);
const [updating, setUpdating] = useState(false);
const paginatedListRef = useRef<IPaginatedListHandle<IScriptBatchSummaryV2>>(
null
);
const statusParam = location?.query.status;
const selectedStatus = statusParam as ScriptBatchStatus;
const queryClient = useQueryClient();
const DEFAULT_PAGE_SIZE = 10;
const fetchPage = useCallback(
(pageNumber: number) => {
setUpdating(true);
return queryClient.fetchQuery(
[
{
team_id: teamId,
status: selectedStatus,
page: pageNumber,
per_page: DEFAULT_PAGE_SIZE,
},
],
({ queryKey }) => {
return scriptsAPI
.getRunScriptBatchSummaries(queryKey[0])
.then((r) => {
setBatchCount(r.count);
return r.batch_executions ?? [];
})
.finally(() => {
setUpdating(false);
});
},
{
staleTime: 100,
}
);
},
[queryClient, selectedStatus, teamId]
);
const handleTabChange = useCallback(
(index: number) => {
const newStatus = STATUS_BY_INDEX[index];
const newParams = new URLSearchParams(location?.search);
newParams.set("status", newStatus);
const newQuery = newParams.toString();
router.push(
PATHS.CONTROLS_SCRIPTS_BATCH_PROGRESS.concat(
newQuery ? `?${newQuery}` : ""
)
);
setBatchCount(null);
setUpdating(false);
},
[location?.search, router]
);
const onClickRow = (r: IScriptBatchSummaryV2) => {
setShowBatchDetailsForSummary({
batch_execution_id: r.batch_execution_id,
script_name: r.script_name,
host_count: r.targeted_host_count,
});
// 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,
targeted_host_count,
ran_host_count,
errored_host_count,
} = summary;
const when = getWhen(summary);
return (
<>
<div className={`${baseClass}__row-left`}>
<b>{script_name}</b>
<div className={`${baseClass}__row-when`}>{when}</div>
</div>
{summary.status !== "scheduled" && (
<div className={`${baseClass}__row-right`}>
<div>
{ran_host_count + errored_host_count} / {targeted_host_count}{" "}
hosts
</div>
<ProgressBar
sections={[
{
// results
color: COLORS["status-success"],
portion: ran_host_count / targeted_host_count,
},
{
// errors
color: COLORS["status-error"],
portion: errored_host_count / targeted_host_count,
},
]}
/>
<div className={`${baseClass}__row-errors`}>
<Icon
name="error-outline"
color="ui-fleet-black-50"
size="small"
/>{" "}
<div>{errored_host_count}</div>
</div>
</div>
)}
</>
);
};
// Fetch the first page of the list when first visiting a tab.
useEffect(() => {
if (batchCount === null && !updating) {
fetchPage(0);
}
}, [batchCount, updating, fetchPage]);
// Reset to first tab if status is invalid.
useEffect(() => {
if (!isValidScriptBatchStatus(statusParam)) {
handleTabChange(0);
}
}, [statusParam, handleTabChange]);
const renderTabContent = (status: ScriptBatchStatus) => {
// If we're switching to a new tab, show the loading spinner
// while we get the first page and # of results.
if (updating && batchCount === null) {
return (
<div className={`${baseClass}__loading`}>
<Spinner />
</div>
);
}
if (batchCount === 0) {
return getEmptyState(status);
}
return (
<div className={`${baseClass}__tab-content`}>
{!updating && batchCount && (
<div className={`${baseClass}__status-count`}>
{batchCount} batch script{batchCount > 1 ? "s" : ""}
</div>
)}
<PaginatedList<IScriptBatchSummaryV2>
ref={paginatedListRef}
count={batchCount || 0}
fetchPage={fetchPage}
onClickRow={onClickRow}
renderItemRow={renderRow}
useCheckBoxes={false}
/>
</div>
);
};
return (
<>
<div className={baseClass}>
<SectionHeader title="Batch progress" alignLeftHeaderVertically />
<TabNav>
<Tabs
selectedIndex={STATUS_BY_INDEX.indexOf(selectedStatus)}
onSelect={handleTabChange}
>
<TabList>
<Tab>
<TabText>Started</TabText>
</Tab>
<Tab>
<TabText>Scheduled</TabText>
</Tab>
<Tab>
<TabText>Finished</TabText>
</Tab>
</TabList>
<TabPanel>{renderTabContent(STATUS_BY_INDEX[0])}</TabPanel>
<TabPanel>{renderTabContent(STATUS_BY_INDEX[1])}</TabPanel>
<TabPanel>{renderTabContent(STATUS_BY_INDEX[2])}</TabPanel>
</Tabs>
</TabNav>
</div>
{batchDetailsForSummary && (
<ScriptBatchSummaryModal
scriptBatchExecutionDetails={{ ...batchDetailsForSummary }}
onCancel={() => {
setShowBatchDetailsForSummary(null);
paginatedListRef.current?.reload();
}}
router={router}
/>
)}
</>
);
};
export default ScriptBatchProgress;