mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #41391 # Details This PR updates front-end API calls to use new URLs and API params, so that the front end doesn't cause deprecation warnings to appear on the server. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. n/a, should not be user-visible ## Testing - [X] Added/updated automated tests - [ ] QA'd all new/changed functionality manually The biggest risk here is not that we missed a spot that still causes a deprecation warning, but that we might inadvertently make a change that breaks the front end, for instance by sending `fleet_id` to a function that drops it silently and thus sends no ID to the server. Fortunately we use TypeScript in virtually every place affected by these changes, so the code would not compile if there were mismatches between the API expectation and what we're sending. Still, spot checking as many places as possible both for deprecation-warning leaks and loss of functionality is important. ## Summary by CodeRabbit * **Refactor** * Updated API nomenclature across the application to use "fleets" instead of "teams" and "reports" instead of "queries" in endpoint paths and request/response payloads. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
648 lines
19 KiB
TypeScript
648 lines
19 KiB
TypeScript
import React, { useContext, useEffect, useState, useRef } from "react";
|
|
import { Row } from "react-table";
|
|
import { useQuery } from "react-query";
|
|
import { useDebouncedCallback } from "use-debounce";
|
|
|
|
import { AppContext } from "context/app";
|
|
|
|
import { IHost } from "interfaces/host";
|
|
import { ILabel, ILabelSummary } from "interfaces/label";
|
|
import {
|
|
ITarget,
|
|
ISelectLabel,
|
|
ISelectTeam,
|
|
ISelectTargetsEntity,
|
|
ISelectedTargetsForApi,
|
|
} from "interfaces/target";
|
|
import { ITeam } from "interfaces/team";
|
|
|
|
import labelsAPI, { ILabelsSummaryResponse } from "services/entities/labels";
|
|
import targetsAPI, {
|
|
ITargetsCountResponse,
|
|
ITargetsSearchResponse,
|
|
} from "services/entities/targets";
|
|
import teamsAPI, { ILoadTeamsResponse } from "services/entities/teams";
|
|
import { formatSelectedTargetsForApi } from "utilities/helpers";
|
|
import { capitalize } from "lodash";
|
|
import permissions from "utilities/permissions";
|
|
|
|
import PageError from "components/DataError";
|
|
import TargetsInput from "components/TargetsInput";
|
|
import { generateTableHeaders } from "components/TargetsInput/TargetsInputHostsTableConfig";
|
|
import Button from "components/buttons/Button";
|
|
import Spinner from "components/Spinner";
|
|
import TooltipWrapper from "components/TooltipWrapper";
|
|
import SearchField from "components/forms/fields/SearchField";
|
|
import RevealButton from "components/buttons/RevealButton";
|
|
import TargetPillSelector from "./TargetChipSelector";
|
|
|
|
interface ISelectTargetsProps {
|
|
baseClass: string;
|
|
queryId?: number | null;
|
|
selectedTargets: ITarget[];
|
|
targetedHosts: IHost[];
|
|
targetedLabels: ILabel[];
|
|
targetedTeams: ITeam[];
|
|
goToQueryEditor: () => void;
|
|
goToRunQuery: () => void;
|
|
setSelectedTargets: // TODO: Refactor policy targets to streamline selectedTargets/selectedTargetsByType
|
|
| React.Dispatch<React.SetStateAction<ITarget[]>> // Used for policies page level useState hook
|
|
| ((value: ITarget[]) => void); // Used for queries app level QueryContext
|
|
setTargetedHosts: React.Dispatch<React.SetStateAction<IHost[]>>;
|
|
setTargetedLabels: React.Dispatch<React.SetStateAction<ILabel[]>>;
|
|
setTargetedTeams: React.Dispatch<React.SetStateAction<ITeam[]>>;
|
|
setTargetsTotalCount: React.Dispatch<React.SetStateAction<number>>;
|
|
isLivePolicy?: boolean;
|
|
isObserverCanRunQuery?: boolean;
|
|
queryTeamId?: number | null;
|
|
}
|
|
|
|
interface ILabelsByType {
|
|
allHosts: ILabelSummary[];
|
|
platforms: ILabelSummary[];
|
|
other: ILabelSummary[];
|
|
}
|
|
|
|
interface ITargetsQueryKey {
|
|
scope: string;
|
|
query_id?: number | null;
|
|
query?: string | null;
|
|
selected?: ISelectedTargetsForApi | null;
|
|
}
|
|
|
|
const DEBOUNCE_DELAY = 500;
|
|
const STALE_TIME = 60000;
|
|
const SECTION_CHARACTER_LIMIT = 600;
|
|
|
|
const isLabel = (entity: ISelectTargetsEntity) => "label_type" in entity;
|
|
const isAllHosts = (entity: ISelectTargetsEntity) =>
|
|
"label_type" in entity &&
|
|
entity.name === "All Hosts" &&
|
|
entity.label_type === "builtin";
|
|
|
|
const parseLabels = (list?: ILabelSummary[]) => {
|
|
const allHosts = list?.filter((l) => l.name === "All Hosts") || [];
|
|
const platforms =
|
|
list?.filter(
|
|
(l) =>
|
|
l.name === "macOS" ||
|
|
l.name === "MS Windows" ||
|
|
l.name === "All Linux" ||
|
|
l.name === "chrome"
|
|
) || [];
|
|
const other = list?.filter((l) => l.label_type === "regular") || [];
|
|
|
|
return { allHosts, platforms, other };
|
|
};
|
|
|
|
/** Returns the index at which the sum of the names in the list exceed the maximum character length */
|
|
const getTruncatedEntityCount = (
|
|
list: ISelectLabel[] | ISelectTeam[],
|
|
maxLength: number
|
|
): number => {
|
|
let totalLength = 0;
|
|
let index = 0;
|
|
while (index < list.length && totalLength < maxLength) {
|
|
totalLength += list[index].name.length;
|
|
index += 1;
|
|
}
|
|
return index;
|
|
};
|
|
|
|
const SelectTargets = ({
|
|
baseClass,
|
|
queryId,
|
|
selectedTargets,
|
|
targetedHosts,
|
|
targetedLabels,
|
|
targetedTeams,
|
|
goToQueryEditor,
|
|
goToRunQuery,
|
|
setSelectedTargets,
|
|
setTargetedHosts,
|
|
setTargetedLabels,
|
|
setTargetedTeams,
|
|
setTargetsTotalCount,
|
|
isLivePolicy,
|
|
isObserverCanRunQuery,
|
|
queryTeamId,
|
|
}: ISelectTargetsProps): JSX.Element => {
|
|
const isMountedRef = useRef(false);
|
|
const { isPremiumTier, isOnGlobalTeam, currentUser } = useContext(AppContext);
|
|
|
|
const [labels, setLabels] = useState<ILabelsByType | null>(null);
|
|
const [searchTextHosts, setSearchTextHosts] = useState("");
|
|
const [searchTextTeams, setSearchTextTeams] = useState("");
|
|
const [searchTextLabels, setSearchTextLabels] = useState("");
|
|
const [isTeamListExpanded, setIsTeamListExpanded] = useState(false);
|
|
const [isLabelsListExpanded, setIsLabelsListExpanded] = useState(false);
|
|
const [debouncedSearchText, setDebouncedSearchText] = useState("");
|
|
const [isDebouncing, setIsDebouncing] = useState(false);
|
|
|
|
const debounceSearch = useDebouncedCallback(
|
|
(search: string) => {
|
|
setDebouncedSearchText(search);
|
|
setIsDebouncing(false);
|
|
},
|
|
DEBOUNCE_DELAY,
|
|
{ trailing: true }
|
|
);
|
|
|
|
const {
|
|
data: teams,
|
|
error: errorTeams,
|
|
isLoading: isLoadingTeams,
|
|
} = useQuery<ILoadTeamsResponse, Error, ITeam[]>(
|
|
["teams"],
|
|
() => teamsAPI.loadAll(),
|
|
{
|
|
select: (data) => data.teams,
|
|
enabled: isPremiumTier,
|
|
staleTime: STALE_TIME, // TODO: confirm
|
|
}
|
|
);
|
|
|
|
const {
|
|
data: labelsSummary,
|
|
error: errorLabels,
|
|
isLoading: isLoadingLabels,
|
|
} = useQuery<ILabelsSummaryResponse, Error, ILabelSummary[]>(
|
|
["labelsSummary"],
|
|
// labels API automatically filters to global/team labels user has access to, so no need for additional params
|
|
() => labelsAPI.summary(),
|
|
{
|
|
select: (data) => data.labels,
|
|
staleTime: STALE_TIME, // TODO: confirm
|
|
}
|
|
);
|
|
|
|
const {
|
|
data: searchResults,
|
|
isFetching: isFetchingSearchResults,
|
|
error: errorSearchResults,
|
|
} = useQuery<ITargetsSearchResponse, Error, IHost[], ITargetsQueryKey[]>(
|
|
[
|
|
{
|
|
scope: "targetsSearch", // TODO: shared scope?
|
|
query_id: queryId,
|
|
query: debouncedSearchText,
|
|
selected: formatSelectedTargetsForApi(selectedTargets),
|
|
},
|
|
],
|
|
({ queryKey }) => {
|
|
const { query_id, query, selected } = queryKey[0];
|
|
return targetsAPI.search({
|
|
report_id: query_id || null,
|
|
query: query || "",
|
|
excluded_host_ids: selected?.hosts || null,
|
|
});
|
|
},
|
|
{
|
|
select: (data) => data.hosts,
|
|
enabled: !!debouncedSearchText,
|
|
// staleTime: 5000, // TODO: try stale time if further performance optimizations are needed
|
|
}
|
|
);
|
|
|
|
const {
|
|
data: counts,
|
|
error: errorCounts,
|
|
isFetching: isFetchingCounts,
|
|
} = useQuery<
|
|
ITargetsCountResponse,
|
|
Error,
|
|
ITargetsCountResponse,
|
|
ITargetsQueryKey[]
|
|
>(
|
|
[
|
|
{
|
|
scope: "targetsCount", // Note: Scope is shared with QueryPage?
|
|
query_id: queryId,
|
|
selected: formatSelectedTargetsForApi(selectedTargets),
|
|
},
|
|
],
|
|
({ queryKey }) => {
|
|
const { query_id, selected } = queryKey[0];
|
|
return targetsAPI.count({
|
|
report_id: query_id,
|
|
selected: selected || null,
|
|
});
|
|
},
|
|
{
|
|
enabled: !!selectedTargets.length,
|
|
onSuccess: (data) => {
|
|
setTargetsTotalCount(data.targets_count || 0);
|
|
},
|
|
staleTime: STALE_TIME, // TODO: confirm
|
|
}
|
|
);
|
|
|
|
// Ensure that the team or label list is expanded on the first load only if a hidden entity is already selected
|
|
const shouldExpandList = (
|
|
targetedList: ISelectLabel[] | ISelectTeam[],
|
|
truncatedList: ISelectLabel[] | ISelectTeam[]
|
|
) => {
|
|
// Set used to improve lookup time
|
|
const truncatedIds = new Set(truncatedList.map((entity) => entity.id));
|
|
|
|
// Check if any entity targeted is not in truncated list shown
|
|
return targetedList.some((entity) => !truncatedIds.has(entity.id));
|
|
};
|
|
|
|
const expandListsOnInitialLoad = () => {
|
|
if (!isMountedRef.current && teams && labels) {
|
|
const truncatedLabels =
|
|
labels?.other?.slice(
|
|
0,
|
|
getTruncatedEntityCount(labels?.other, SECTION_CHARACTER_LIMIT)
|
|
) || [];
|
|
const truncatedTeams =
|
|
teams?.slice(
|
|
0,
|
|
getTruncatedEntityCount(teams, SECTION_CHARACTER_LIMIT)
|
|
) || [];
|
|
|
|
if (shouldExpandList(targetedLabels, truncatedLabels)) {
|
|
setIsLabelsListExpanded(true);
|
|
}
|
|
|
|
if (shouldExpandList(targetedTeams, truncatedTeams)) {
|
|
setIsTeamListExpanded(true);
|
|
}
|
|
|
|
isMountedRef.current = true;
|
|
}
|
|
};
|
|
|
|
useEffect(expandListsOnInitialLoad, [
|
|
targetedTeams,
|
|
targetedLabels,
|
|
labels,
|
|
teams,
|
|
isMountedRef,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const selected = [...targetedHosts, ...targetedLabels, ...targetedTeams];
|
|
setSelectedTargets(selected);
|
|
}, [targetedHosts, targetedLabels, targetedTeams]);
|
|
|
|
useEffect(() => {
|
|
labelsSummary && setLabels(parseLabels(labelsSummary));
|
|
}, [labelsSummary]);
|
|
|
|
useEffect(() => {
|
|
setIsDebouncing(true);
|
|
debounceSearch(searchTextHosts);
|
|
}, [searchTextHosts]);
|
|
|
|
const handleClickCancel = () => {
|
|
goToQueryEditor();
|
|
};
|
|
|
|
const handleButtonSelect = (selectedEntity: ISelectTargetsEntity) => (
|
|
e: React.MouseEvent<HTMLButtonElement>
|
|
): void => {
|
|
e.preventDefault();
|
|
|
|
const prevTargets: ISelectTargetsEntity[] = isLabel(selectedEntity)
|
|
? targetedLabels
|
|
: targetedTeams;
|
|
|
|
// if the target was previously selected, we want to remove it now
|
|
let newTargets = prevTargets.filter((t) => t.id !== selectedEntity.id);
|
|
// if the length remains the same, the target was not previously selected so we want to add it now
|
|
prevTargets.length === newTargets.length && newTargets.push(selectedEntity);
|
|
|
|
// Logic when to deselect/select "all hosts" when using more granulated filters
|
|
// If "all hosts" is selected
|
|
if (isAllHosts(selectedEntity)) {
|
|
// and "all hosts" is already selected, deselect it
|
|
if (targetedLabels.some((t) => isAllHosts(t))) {
|
|
newTargets = [];
|
|
} // else deselect everything but "all hosts"
|
|
else {
|
|
newTargets = [selectedEntity];
|
|
}
|
|
setTargetedTeams([]);
|
|
setTargetedHosts([]);
|
|
}
|
|
// else deselect "all hosts"
|
|
else {
|
|
if (targetedLabels.some((t) => isAllHosts(t))) {
|
|
setTargetedLabels([]);
|
|
}
|
|
newTargets = newTargets.filter((t) => !isAllHosts(t));
|
|
}
|
|
|
|
isLabel(selectedEntity)
|
|
? setTargetedLabels(newTargets as ILabel[])
|
|
: setTargetedTeams(newTargets as ITeam[]);
|
|
};
|
|
|
|
const handleRowSelect = (row: Row<IHost>) => {
|
|
setTargetedHosts((prevHosts) => prevHosts.concat(row.original));
|
|
setSearchTextHosts("");
|
|
|
|
// If "all hosts" is already selected when using host target picker, deselect "all hosts"
|
|
if (targetedLabels.some((t) => isAllHosts(t))) {
|
|
setTargetedLabels([]);
|
|
}
|
|
};
|
|
|
|
const handleRowRemove = (row: Row<IHost>) => {
|
|
const removedHost = row.original;
|
|
setTargetedHosts((prevHosts) =>
|
|
prevHosts.filter((h) => h.id !== removedHost.id)
|
|
);
|
|
};
|
|
|
|
const onClickRun = () => {
|
|
setTargetsTotalCount(counts?.targets_count || 0);
|
|
goToRunQuery();
|
|
};
|
|
|
|
const renderTargetEntitySection = (
|
|
entityType: string,
|
|
entityList: ISelectLabel[] | ISelectTeam[],
|
|
disabledIds?: Set<number>
|
|
): JSX.Element => {
|
|
const isTeamsSection = entityType === "teams";
|
|
const displayType = isTeamsSection ? "fleets" : entityType;
|
|
const isSearchEnabled = isTeamsSection || entityType === "labels";
|
|
const searchTerm = (
|
|
(isTeamsSection ? searchTextTeams : searchTextLabels) || ""
|
|
).toLowerCase();
|
|
const arrFixed = entityList as Array<typeof entityList[number]>;
|
|
const filteredEntities = isSearchEnabled
|
|
? arrFixed.filter((entity: ISelectLabel | ISelectTeam) => {
|
|
if (isSearchEnabled) {
|
|
return searchTerm
|
|
? entity.name.toLowerCase().includes(searchTerm)
|
|
: true;
|
|
}
|
|
return true;
|
|
})
|
|
: arrFixed;
|
|
|
|
const isListExpanded = isTeamsSection
|
|
? isTeamListExpanded
|
|
: isLabelsListExpanded;
|
|
const truncatedEntities = filteredEntities.slice(
|
|
0,
|
|
getTruncatedEntityCount(filteredEntities, SECTION_CHARACTER_LIMIT)
|
|
);
|
|
const hiddenEntityCount =
|
|
filteredEntities.length - truncatedEntities.length;
|
|
|
|
const toggleExpansion = () => {
|
|
isTeamsSection
|
|
? setIsTeamListExpanded(!isTeamListExpanded)
|
|
: setIsLabelsListExpanded(!isLabelsListExpanded);
|
|
};
|
|
|
|
const entitiesToDisplay = isListExpanded
|
|
? filteredEntities
|
|
: truncatedEntities;
|
|
|
|
const emptySearchString = `No matching ${displayType}.`;
|
|
|
|
const renderEmptySearchString = () => {
|
|
if (entitiesToDisplay.length === 0 && searchTerm !== "") {
|
|
return (
|
|
<div className={`${baseClass}__empty-entity-search`}>
|
|
{emptySearchString}
|
|
</div>
|
|
);
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{entityType && <h3>{capitalize(displayType)}</h3>}
|
|
{isTeamsSection && !!disabledIds?.size && (
|
|
<p className={`${baseClass}__team-help-text`}>
|
|
Results limited to fleets you can access.
|
|
</p>
|
|
)}
|
|
{isSearchEnabled && (
|
|
<>
|
|
<SearchField
|
|
placeholder={`Search ${displayType}`}
|
|
onChange={(searchString) => {
|
|
isTeamsSection
|
|
? setSearchTextTeams(searchString)
|
|
: setSearchTextLabels(searchString);
|
|
}}
|
|
clearButton
|
|
/>
|
|
{renderEmptySearchString()}
|
|
</>
|
|
)}
|
|
<div className="selector-block">
|
|
{entitiesToDisplay?.map((entity: ISelectLabel | ISelectTeam) => {
|
|
const targetList = isLabel(entity) ? targetedLabels : targetedTeams;
|
|
return (
|
|
<TargetPillSelector
|
|
key={`${isLabel(entity) ? "label" : "team"}__${entity.id}`}
|
|
entity={entity}
|
|
isSelected={targetList.some((t) => t.id === entity.id)}
|
|
onClick={handleButtonSelect}
|
|
disabled={disabledIds?.has(entity.id)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
{hiddenEntityCount > 0 && (
|
|
<div className="expand-button-wrap">
|
|
<RevealButton
|
|
onClick={toggleExpansion}
|
|
caretPosition="after"
|
|
showText="Show more"
|
|
hideText="Show less"
|
|
isShowing={isListExpanded}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const renderTargetsCount = (): JSX.Element | null => {
|
|
if (isFetchingCounts) {
|
|
return (
|
|
<>
|
|
<Spinner
|
|
size="x-small"
|
|
includeContainer={false}
|
|
centered={false}
|
|
className={`${baseClass}__count-spinner`}
|
|
/>
|
|
<i style={{ color: "#8b8fa2" }}>Counting hosts</i>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (errorCounts) {
|
|
return (
|
|
<b style={{ color: "#d66c7b", margin: 0 }}>
|
|
There was a problem counting hosts. Please try again later.
|
|
</b>
|
|
);
|
|
}
|
|
|
|
if (!counts) {
|
|
return null;
|
|
}
|
|
|
|
const { targets_count: total, targets_online: online } = counts;
|
|
const onlinePercentage = () => {
|
|
if (total === 0 || online === 0) {
|
|
return 0;
|
|
}
|
|
// If at least 1 host is online, displays <1% instead of 0%
|
|
const roundPercentage =
|
|
Math.round((online / total) * 100) === 0
|
|
? "<1"
|
|
: Math.round((online / total) * 100);
|
|
return roundPercentage;
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<b>{total.toLocaleString()}</b> host
|
|
{total > 1 || total === 0 ? `s` : ``} targeted (
|
|
{onlinePercentage()}
|
|
%
|
|
<TooltipWrapper
|
|
tipContent={
|
|
<>
|
|
Hosts are online if they <br />
|
|
have recently checked <br />
|
|
into Fleet.
|
|
</>
|
|
}
|
|
>
|
|
online
|
|
</TooltipWrapper>
|
|
){" "}
|
|
</>
|
|
);
|
|
};
|
|
|
|
if (errorLabels || errorTeams) {
|
|
return (
|
|
<div className={`${baseClass}__wrapper`}>
|
|
<h1>Select targets</h1>
|
|
<PageError />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const resultsTableConfig = generateTableHeaders();
|
|
const selectedHostsTableConfig = generateTableHeaders(handleRowRemove);
|
|
|
|
const shouldDisableForObserver = (teamId: number): boolean => {
|
|
if (isLivePolicy) return true;
|
|
if (!isObserverCanRunQuery) return true;
|
|
// observer_can_run is scoped to the query's own team; plain observers cannot
|
|
// target teams other than the one the query belongs to.
|
|
if (queryTeamId != null && queryTeamId !== teamId) return true;
|
|
return false;
|
|
};
|
|
|
|
const getDisabledTeamIds = (): Set<number> => {
|
|
const disabled = new Set<number>();
|
|
|
|
const isGlobalPlainObserver = currentUser?.global_role === "observer";
|
|
|
|
if (isGlobalPlainObserver) {
|
|
// Global plain observers have the same restrictions as team-level
|
|
// observers but applied to ALL teams/fleets (including "Unassigned")
|
|
const allTeamIds = [...(teams?.map((t) => t.id) || []), 0]; // 0 = "Unassigned"
|
|
allTeamIds.forEach((teamId) => {
|
|
if (shouldDisableForObserver(teamId)) {
|
|
disabled.add(teamId);
|
|
}
|
|
});
|
|
return disabled;
|
|
}
|
|
|
|
// Team/fleet-level plain observer logic
|
|
teams?.forEach((team) => {
|
|
const isPlainObserver =
|
|
permissions.isTeamObserver(currentUser, team.id) &&
|
|
!permissions.isTeamObserverPlus(currentUser, team.id);
|
|
if (!isPlainObserver) return;
|
|
|
|
if (shouldDisableForObserver(team.id)) {
|
|
disabled.add(team.id);
|
|
}
|
|
});
|
|
return disabled;
|
|
};
|
|
|
|
const disabledTeamIds = getDisabledTeamIds();
|
|
|
|
if (isLoadingLabels || isLoadingTeams) {
|
|
return <Spinner />;
|
|
}
|
|
|
|
return (
|
|
<div className={`${baseClass}__wrapper`}>
|
|
<h1>Select targets</h1>
|
|
<div className={`${baseClass}__target-selectors`}>
|
|
{!!labels?.allHosts.length &&
|
|
renderTargetEntitySection("", labels.allHosts)}
|
|
{!!labels?.platforms?.length &&
|
|
renderTargetEntitySection("Platforms", labels.platforms)}
|
|
{!!teams?.length &&
|
|
(isOnGlobalTeam
|
|
? renderTargetEntitySection(
|
|
"teams",
|
|
[{ id: 0, name: "Unassigned" }, ...teams],
|
|
disabledTeamIds
|
|
)
|
|
: renderTargetEntitySection("teams", teams, disabledTeamIds))}
|
|
{!!labels?.other?.length &&
|
|
renderTargetEntitySection("labels", labels.other)}
|
|
</div>
|
|
<TargetsInput
|
|
autofocus
|
|
searchResultsTableConfig={resultsTableConfig}
|
|
selectedHostsTableConifg={selectedHostsTableConfig}
|
|
searchText={searchTextHosts}
|
|
searchResults={searchResults || []}
|
|
isTargetsLoading={isFetchingSearchResults || isDebouncing}
|
|
targetedHosts={targetedHosts}
|
|
hasFetchError={!!errorSearchResults}
|
|
setSearchText={setSearchTextHosts}
|
|
handleRowSelect={handleRowSelect}
|
|
disablePagination
|
|
/>
|
|
<div className={`${baseClass}__targets-button-wrap`}>
|
|
<Button
|
|
className={`${baseClass}__btn`}
|
|
type="button"
|
|
disabled={isFetchingCounts || !counts?.targets_count} // TODO: confirm
|
|
onClick={onClickRun}
|
|
>
|
|
Run
|
|
</Button>
|
|
<Button
|
|
className={`${baseClass}__btn`}
|
|
onClick={handleClickCancel}
|
|
variant="inverse"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<div className={`${baseClass}__targets-total-count`}>
|
|
{renderTargetsCount()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SelectTargets;
|