mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
## 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>
253 lines
7.1 KiB
TypeScript
253 lines
7.1 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 { 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 Icon from "components/Icon/Icon";
|
|
|
|
import { IScriptsCommonProps } from "../../ScriptsNavItems";
|
|
import getWhen from "../../helpers";
|
|
|
|
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;
|
|
|
|
const ScriptBatchProgress = ({
|
|
location,
|
|
router,
|
|
teamId,
|
|
}: IScriptBatchProgressProps) => {
|
|
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) => {
|
|
router.push(PATHS.CONTROLS_SCRIPTS_BATCH_DETAILS(r.batch_execution_id));
|
|
// return satisfies caller expectations, not used in this case
|
|
return r;
|
|
};
|
|
|
|
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>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default ScriptBatchProgress;
|