mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
Fixes https://github.com/fleetdm/fleet/pull/42504/changes#diff-7616ec5052d5650a10c334f3d7a211aaeaa39f99b3d41d666931ec1796fcbabbR317
2073 lines
62 KiB
TypeScript
2073 lines
62 KiB
TypeScript
import React, {
|
||
useState,
|
||
useContext,
|
||
useEffect,
|
||
useCallback,
|
||
useMemo,
|
||
} from "react";
|
||
import { useQuery } from "react-query";
|
||
import { Row } from "react-table";
|
||
import { InjectedRouter, Params } from "react-router/lib/Router";
|
||
import { RouteProps } from "react-router/lib/Route";
|
||
import { find, isEmpty, isEqual, omit } from "lodash";
|
||
import { format } from "date-fns";
|
||
import FileSaver from "file-saver";
|
||
|
||
import scriptsAPI, {
|
||
IScriptBatchSummaryQueryKey,
|
||
IScriptBatchSummaryV1,
|
||
ScriptBatchHostCountV1,
|
||
} from "services/entities/scripts";
|
||
import enrollSecretsAPI from "services/entities/enroll_secret";
|
||
import usersAPI from "services/entities/users";
|
||
import labelsAPI, { ILabelsResponse } from "services/entities/labels";
|
||
import teamsAPI, { ILoadTeamsResponse } from "services/entities/teams";
|
||
import globalPoliciesAPI from "services/entities/global_policies";
|
||
import hostsAPI, {
|
||
HOSTS_QUERY_PARAMS as PARAMS,
|
||
ILoadHostsQueryKey,
|
||
ILoadHostsResponse,
|
||
ISortOption,
|
||
MacSettingsStatusQueryParam,
|
||
HOSTS_QUERY_PARAMS,
|
||
} from "services/entities/hosts";
|
||
import hostCountAPI, {
|
||
IHostsCountQueryKey,
|
||
IHostsCountResponse,
|
||
} from "services/entities/host_count";
|
||
import configProfileAPI, {
|
||
IGetConfigProfileResponse,
|
||
} from "services/entities/config_profiles";
|
||
|
||
import {
|
||
getOSVersions,
|
||
IGetOSVersionsQueryKey,
|
||
IOSVersionsResponse,
|
||
} from "services/entities/operating_systems";
|
||
|
||
import PATHS from "router/paths";
|
||
import { AppContext } from "context/app";
|
||
import { TableContext } from "context/table";
|
||
import { NotificationContext } from "context/notification";
|
||
|
||
import useTeamIdParam from "hooks/useTeamIdParam";
|
||
|
||
import {
|
||
IEnrollSecret,
|
||
IEnrollSecretsResponse,
|
||
} from "interfaces/enroll_secret";
|
||
import { ILabel } from "interfaces/label";
|
||
import { IOperatingSystemVersion } from "interfaces/operating_system";
|
||
import { IPolicy, IStoredPolicyResponse } from "interfaces/policy";
|
||
import {
|
||
isValidSoftwareAggregateStatus,
|
||
SoftwareAggregateStatus,
|
||
SCRIPT_PACKAGE_SOURCES,
|
||
} from "interfaces/software";
|
||
import { API_ALL_TEAMS_ID, ITeam } from "interfaces/team";
|
||
import { IEmptyTableProps } from "interfaces/empty_table";
|
||
import {
|
||
DiskEncryptionStatus,
|
||
BootstrapPackageStatus,
|
||
MdmProfileStatus,
|
||
} from "interfaces/mdm";
|
||
|
||
import sortUtils from "utilities/sort";
|
||
import {
|
||
DEFAULT_USE_QUERY_OPTIONS,
|
||
HOSTS_SEARCH_BOX_PLACEHOLDER,
|
||
HOSTS_SEARCH_BOX_TOOLTIP,
|
||
MAX_SCRIPT_BATCH_TARGETS,
|
||
PolicyResponse,
|
||
} from "utilities/constants";
|
||
import { getNextLocationPath } from "utilities/helpers";
|
||
import { strToBool } from "utilities/strings/stringUtils";
|
||
|
||
import Button from "components/buttons/Button";
|
||
import Icon from "components/Icon/Icon";
|
||
import { SingleValue } from "react-select-5";
|
||
import DropdownWrapper from "components/forms/fields/DropdownWrapper";
|
||
import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper";
|
||
import TableContainer from "components/TableContainer";
|
||
import InfoBanner from "components/InfoBanner/InfoBanner";
|
||
import { ITableQueryData } from "components/TableContainer/TableContainer";
|
||
import TableCount from "components/TableContainer/TableCount";
|
||
import DataError from "components/DataError";
|
||
import { IActionButtonProps } from "components/TableContainer/DataTable/ActionButton/ActionButton";
|
||
import TeamsDropdown from "components/TeamsDropdown";
|
||
import Spinner from "components/Spinner";
|
||
import MainContent from "components/MainContent";
|
||
import EmptyTable from "components/EmptyTable";
|
||
import {
|
||
defaultHiddenColumns,
|
||
generateVisibleTableColumns,
|
||
generateAvailableTableHeaders,
|
||
} from "./HostTableConfig";
|
||
import {
|
||
LABEL_SLUG_PREFIX,
|
||
DEFAULT_SORT_HEADER,
|
||
DEFAULT_SORT_DIRECTION,
|
||
DEFAULT_PAGE_SIZE,
|
||
DEFAULT_PAGE_INDEX,
|
||
hostSelectStatuses,
|
||
MANAGE_HOSTS_PAGE_FILTER_KEYS,
|
||
MANAGE_HOSTS_PAGE_LABEL_INCOMPATIBLE_QUERY_PARAMS,
|
||
} from "./HostsPageConfig";
|
||
import { getDeleteLabelErrorMessages, isAcceptableStatus } from "./helpers";
|
||
|
||
import DeleteSecretModal from "../../../components/EnrollSecrets/DeleteSecretModal";
|
||
import SecretEditorModal from "../../../components/EnrollSecrets/SecretEditorModal";
|
||
import AddHostsModal from "../../../components/AddHostsModal";
|
||
import EnrollSecretModal from "../../../components/EnrollSecrets/EnrollSecretModal";
|
||
// @ts-ignore
|
||
import EditColumnsModal from "./components/EditColumnsModal/EditColumnsModal";
|
||
import TransferHostModal from "../components/TransferHostModal";
|
||
import DeleteHostModal from "../components/DeleteHostModal";
|
||
import DeleteLabelModal from "./components/DeleteLabelModal";
|
||
import LabelFilterSelect from "./components/LabelFilterSelect";
|
||
import HostsFilterBlock from "./components/HostsFilterBlock";
|
||
import RunScriptBatchModal from "./components/RunScriptBatchModal";
|
||
|
||
interface IManageHostsProps {
|
||
route: RouteProps;
|
||
router: InjectedRouter;
|
||
params: Params;
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
location: any; // no type in react-router v3 TODO: Improve this type
|
||
}
|
||
|
||
interface IRowProps extends Row {
|
||
original: {
|
||
id?: number;
|
||
};
|
||
}
|
||
|
||
const CSV_HOSTS_TITLE = "Hosts";
|
||
const baseClass = "manage-hosts";
|
||
|
||
const ManageHostsPage = ({
|
||
route,
|
||
router,
|
||
params: routeParams,
|
||
location,
|
||
}: IManageHostsProps): JSX.Element => {
|
||
const routeTemplate = route?.path ?? "";
|
||
const queryParams = location.query;
|
||
const {
|
||
config,
|
||
currentUser,
|
||
filteredHostsPath,
|
||
isGlobalAdmin,
|
||
isGlobalMaintainer,
|
||
isGlobalTechnician,
|
||
isOnGlobalTeam,
|
||
isOnlyObserver,
|
||
isPremiumTier,
|
||
isFreeTier,
|
||
userSettings,
|
||
setFilteredHostsPath,
|
||
setFilteredPoliciesPath,
|
||
setFilteredQueriesPath,
|
||
setFilteredSoftwarePath,
|
||
} = useContext(AppContext);
|
||
const isPrimoMode = config?.partnerships?.enable_primo;
|
||
const { renderFlash } = useContext(NotificationContext);
|
||
|
||
const { setResetSelectedRows } = useContext(TableContext);
|
||
|
||
const shouldStripScriptBatchExecParamOnTeamChange = (
|
||
newTeamId?: number,
|
||
curTeamId?: number
|
||
) => newTeamId !== curTeamId;
|
||
|
||
const {
|
||
currentTeamId,
|
||
isAllTeamsSelected,
|
||
currentTeamName,
|
||
isAnyTeamSelected,
|
||
isRouteOk,
|
||
isTeamAdmin,
|
||
isTeamMaintainer,
|
||
isTeamTechnician,
|
||
isTeamMaintainerOrTeamAdmin,
|
||
teamIdForApi,
|
||
userTeams,
|
||
handleTeamChange,
|
||
} = useTeamIdParam({
|
||
location,
|
||
router,
|
||
includeAllTeams: true,
|
||
includeNoTeam: true,
|
||
overrideParamsOnTeamChange: {
|
||
// remove the software status filter when selecting All teams
|
||
[HOSTS_QUERY_PARAMS.SOFTWARE_STATUS]: (newTeamId?: number) =>
|
||
newTeamId === API_ALL_TEAMS_ID,
|
||
// remove batch script summary results filters on team change
|
||
[HOSTS_QUERY_PARAMS.SCRIPT_BATCH_EXECUTION_ID]: shouldStripScriptBatchExecParamOnTeamChange,
|
||
[HOSTS_QUERY_PARAMS.SCRIPT_BATCH_EXECUTION_STATUS]: shouldStripScriptBatchExecParamOnTeamChange,
|
||
},
|
||
});
|
||
|
||
// Functions to avoid race conditions
|
||
const initialSortBy: ISortOption[] = (() => {
|
||
let key = DEFAULT_SORT_HEADER;
|
||
let direction = DEFAULT_SORT_DIRECTION;
|
||
|
||
if (queryParams) {
|
||
const { order_key, order_direction } = queryParams;
|
||
key = order_key || key;
|
||
direction = order_direction || direction;
|
||
}
|
||
|
||
return [{ key, direction }];
|
||
})();
|
||
const initialQuery = (() => queryParams.query ?? "")();
|
||
const curPageFromURL = (() =>
|
||
queryParams && queryParams.page ? parseInt(queryParams?.page, 10) : 0)();
|
||
|
||
// ========= states
|
||
const [showDeleteSecretModal, setShowDeleteSecretModal] = useState(false);
|
||
const [showSecretEditorModal, setShowSecretEditorModal] = useState(false);
|
||
const [showEnrollSecretModal, setShowEnrollSecretModal] = useState(false);
|
||
const [showDeleteLabelModal, setShowDeleteLabelModal] = useState(false);
|
||
const [showEditColumnsModal, setShowEditColumnsModal] = useState(false);
|
||
const [showAddHostsModal, setShowAddHostsModal] = useState(false);
|
||
const [showTransferHostModal, setShowTransferHostModal] = useState(false);
|
||
const [showDeleteHostModal, setShowDeleteHostModal] = useState(false);
|
||
const [showRunScriptBatchModal, setShowRunScriptBatchModal] = useState(false);
|
||
|
||
const [hiddenColumns, setHiddenColumns] = useState<string[]>(
|
||
userSettings?.hidden_host_columns || defaultHiddenColumns
|
||
);
|
||
|
||
const [selectedLabel, setSelectedLabel] = useState<ILabel>();
|
||
const [selectedSecret, setSelectedSecret] = useState<IEnrollSecret>();
|
||
|
||
const [selectedHostIds, setSelectedHostIds] = useState<number[]>([]);
|
||
const [isAllMatchingHostsSelected, setIsAllMatchingHostsSelected] = useState(
|
||
false
|
||
);
|
||
const [searchQuery, setSearchQuery] = useState(initialQuery);
|
||
const [sortBy, setSortBy] = useState<ISortOption[]>(initialSortBy);
|
||
const [tableQueryData, setTableQueryData] = useState<ITableQueryData>();
|
||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||
|
||
// ========= queryParams
|
||
const policyId = queryParams?.policy_id;
|
||
const policyResponse: PolicyResponse = queryParams?.policy_response;
|
||
const macSettingsStatus = queryParams?.macos_settings;
|
||
const softwareId =
|
||
queryParams?.software_id !== undefined
|
||
? parseInt(queryParams.software_id, 10)
|
||
: undefined;
|
||
const softwareVersionId =
|
||
queryParams?.software_version_id !== undefined
|
||
? parseInt(queryParams.software_version_id, 10)
|
||
: undefined;
|
||
const softwareTitleId =
|
||
queryParams?.software_title_id !== undefined
|
||
? parseInt(queryParams.software_title_id, 10)
|
||
: undefined;
|
||
const softwareStatus = isValidSoftwareAggregateStatus(
|
||
queryParams?.[HOSTS_QUERY_PARAMS.SOFTWARE_STATUS]
|
||
)
|
||
? (queryParams[
|
||
HOSTS_QUERY_PARAMS.SOFTWARE_STATUS
|
||
] as SoftwareAggregateStatus)
|
||
: undefined;
|
||
const status = isAcceptableStatus(queryParams?.status)
|
||
? queryParams?.status
|
||
: undefined;
|
||
const mdmId =
|
||
queryParams?.mdm_id !== undefined
|
||
? parseInt(queryParams.mdm_id, 10)
|
||
: undefined;
|
||
const mdmEnrollmentStatus = queryParams?.mdm_enrollment_status;
|
||
const {
|
||
os_version_id: osVersionId,
|
||
os_name: osName,
|
||
os_version: osVersion,
|
||
} = queryParams;
|
||
const vulnerability = queryParams?.vulnerability;
|
||
const munkiIssueId =
|
||
queryParams?.munki_issue_id !== undefined
|
||
? parseInt(queryParams.munki_issue_id, 10)
|
||
: undefined;
|
||
const lowDiskSpaceHosts =
|
||
queryParams?.low_disk_space !== undefined
|
||
? parseInt(queryParams.low_disk_space, 10)
|
||
: undefined;
|
||
const missingHosts = queryParams?.status === "missing";
|
||
const osSettingsStatus = queryParams?.[PARAMS.OS_SETTINGS];
|
||
const diskEncryptionStatus: DiskEncryptionStatus | undefined =
|
||
queryParams?.[PARAMS.DISK_ENCRYPTION];
|
||
const bootstrapPackageStatus: BootstrapPackageStatus | undefined =
|
||
queryParams?.bootstrap_package;
|
||
const configProfileStatus = queryParams?.profile_status;
|
||
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);
|
||
const depProfileError = queryParams?.dep_profile_error;
|
||
/** URL converts to lowercase but API and UI requires uppercase */
|
||
const depAssignProfileResponse = queryParams?.dep_assign_profile_response?.toUpperCase();
|
||
|
||
// ========= routeParams
|
||
const { active_label: activeLabel, label_id: labelID } = routeParams;
|
||
const selectedLabels = useMemo(() => {
|
||
const filters: string[] = [];
|
||
labelID && filters.push(`${LABEL_SLUG_PREFIX}${labelID}`);
|
||
activeLabel && filters.push(activeLabel);
|
||
return filters;
|
||
}, [activeLabel, labelID]);
|
||
|
||
// All possible filter states - these align with ILoadHostsOptions, but state names here can
|
||
// differ from param names there:
|
||
// searchQuery ||
|
||
// teamId ||
|
||
// labelID / active_label
|
||
// policyId ||
|
||
// macSettingsStatus ||
|
||
// policyResponse ||
|
||
// softwareId ||
|
||
// softwareTitleId ||
|
||
// softwareVersionId ||
|
||
// softwareStatus ||
|
||
// status ||
|
||
// osName ||
|
||
// osVersionId ||
|
||
// osVersion ||
|
||
// macSettingsStatus ||
|
||
// bootstrapPackageStatus ||
|
||
// mdmId ||
|
||
// mdmEnrollmentStatus ||
|
||
// munkiIssueId ||
|
||
// lowDiskSpaceHosts ||
|
||
// missingHosts ||
|
||
// osSettingsStatus ||
|
||
// diskEncryptionStatus ||
|
||
// vulnerability ||
|
||
// scriptBatchExecutionId ||
|
||
// scriptBatchExecutionStatus
|
||
// configProfileStatus ||
|
||
// configProfileUUID
|
||
// depProfileError
|
||
// depAssignProfileResponse
|
||
const runScriptBatchFilterNotSupported = !!(
|
||
// all above, except acceptable filters
|
||
(
|
||
diskEncryptionStatus ||
|
||
policyId ||
|
||
macSettingsStatus ||
|
||
policyResponse ||
|
||
softwareId ||
|
||
softwareTitleId ||
|
||
softwareVersionId ||
|
||
softwareStatus ||
|
||
// the 4 allowed filters:
|
||
// // team
|
||
// teamId ||
|
||
// // query (query string)
|
||
// searchQuery ||
|
||
// // label
|
||
// labelID / active_label
|
||
// // status
|
||
// status ||
|
||
osName ||
|
||
osVersionId ||
|
||
osVersion ||
|
||
macSettingsStatus ||
|
||
bootstrapPackageStatus ||
|
||
mdmId ||
|
||
mdmEnrollmentStatus ||
|
||
munkiIssueId ||
|
||
lowDiskSpaceHosts ||
|
||
missingHosts ||
|
||
osSettingsStatus ||
|
||
diskEncryptionStatus ||
|
||
vulnerability ||
|
||
scriptBatchExecutionId ||
|
||
scriptBatchExecutionStatus ||
|
||
configProfileStatus ||
|
||
configProfileUUID ||
|
||
depProfileError ||
|
||
depAssignProfileResponse
|
||
)
|
||
);
|
||
|
||
// ========= derived permissions
|
||
const canEnrollHosts =
|
||
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
|
||
const canEnrollGlobalHosts = isGlobalAdmin || isGlobalMaintainer;
|
||
const canAddNewLabels =
|
||
(isGlobalAdmin ||
|
||
isGlobalMaintainer ||
|
||
isGlobalTechnician ||
|
||
isTeamAdmin ||
|
||
isTeamMaintainer ||
|
||
isTeamTechnician) ??
|
||
false;
|
||
const canRunScriptBatch =
|
||
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
|
||
|
||
const {
|
||
data: labels,
|
||
refetch: refetchLabels,
|
||
isLoading: isLoadingLabels,
|
||
} = useQuery<ILabelsResponse, Error, ILabel[]>(
|
||
["labels", currentTeamId],
|
||
() => labelsAPI.loadAll(currentTeamId),
|
||
{
|
||
enabled: isRouteOk,
|
||
select: (data: ILabelsResponse) => data.labels,
|
||
}
|
||
);
|
||
|
||
const {
|
||
isLoading: isGlobalSecretsLoading,
|
||
data: globalSecrets,
|
||
refetch: refetchGlobalSecrets,
|
||
} = useQuery<IEnrollSecretsResponse, Error, IEnrollSecret[]>(
|
||
["global secrets"],
|
||
() => enrollSecretsAPI.getGlobalEnrollSecrets(),
|
||
{
|
||
enabled: isRouteOk && !!canEnrollGlobalHosts,
|
||
select: (data: IEnrollSecretsResponse) => data.secrets,
|
||
}
|
||
);
|
||
|
||
const {
|
||
isLoading: isTeamSecretsLoading,
|
||
data: teamSecrets,
|
||
refetch: refetchTeamSecrets,
|
||
} = useQuery<IEnrollSecretsResponse, Error, IEnrollSecret[]>(
|
||
["team secrets", currentTeamId],
|
||
() => {
|
||
if (isAnyTeamSelected) {
|
||
return enrollSecretsAPI.getTeamEnrollSecrets(currentTeamId);
|
||
}
|
||
return { secrets: [] };
|
||
},
|
||
{
|
||
enabled: isRouteOk && isAnyTeamSelected && canEnrollHosts,
|
||
select: (data: IEnrollSecretsResponse) => data.secrets,
|
||
}
|
||
);
|
||
|
||
const {
|
||
data: teams,
|
||
isLoading: isLoadingTeams,
|
||
refetch: refetchTeams,
|
||
} = useQuery<ILoadTeamsResponse, Error, ITeam[]>(
|
||
["teams"],
|
||
() => teamsAPI.loadAll(),
|
||
{
|
||
enabled: isRouteOk && !!isPremiumTier,
|
||
select: (data: ILoadTeamsResponse) =>
|
||
data.teams.sort((a, b) => sortUtils.caseInsensitiveAsc(a.name, b.name)),
|
||
}
|
||
);
|
||
|
||
const {
|
||
data: policy,
|
||
isLoading: isLoadingPolicy,
|
||
error: errorPolicy,
|
||
} = useQuery<IStoredPolicyResponse, Error, IPolicy>(
|
||
["policy", policyId],
|
||
() => globalPoliciesAPI.load(policyId),
|
||
{
|
||
enabled: isRouteOk && !!policyId,
|
||
select: (data) => data.policy,
|
||
}
|
||
);
|
||
|
||
const {
|
||
data: scriptBatchSummary,
|
||
isLoading: isLoadingScriptBatchSummary,
|
||
isError: isErrorScriptBatchSummary,
|
||
} = useQuery<
|
||
IScriptBatchSummaryV1,
|
||
Error,
|
||
IScriptBatchSummaryV1,
|
||
IScriptBatchSummaryQueryKey[]
|
||
>(
|
||
[
|
||
{
|
||
scope: "script_batch_summary",
|
||
batch_execution_id: scriptBatchExecutionId,
|
||
},
|
||
],
|
||
({ queryKey: [{ batch_execution_id }] }) =>
|
||
scriptsAPI.getRunScriptBatchSummaryV1({ batch_execution_id }),
|
||
{
|
||
enabled: !!scriptBatchExecutionId && isRouteOk,
|
||
...DEFAULT_USE_QUERY_OPTIONS,
|
||
}
|
||
);
|
||
|
||
const {
|
||
data: configProfile,
|
||
isLoading: isLoadingConfigProfile,
|
||
error: errorConfigProfile,
|
||
} = useQuery<IGetConfigProfileResponse, Error>(
|
||
["config-profile", configProfileUUID],
|
||
() => configProfileAPI.getConfigProfile(configProfileUUID),
|
||
{
|
||
enabled: isRouteOk && !!configProfileUUID,
|
||
}
|
||
);
|
||
|
||
const { data: osVersions, isLoading: isLoadingOsVersions } = useQuery<
|
||
IOSVersionsResponse,
|
||
Error,
|
||
IOperatingSystemVersion[],
|
||
IGetOSVersionsQueryKey[]
|
||
>([{ scope: "os_versions" }], () => getOSVersions(), {
|
||
enabled:
|
||
isRouteOk &&
|
||
(!!queryParams?.os_version_id ||
|
||
(!!queryParams?.os_name && !!queryParams?.os_version)),
|
||
keepPreviousData: true,
|
||
select: (data) => data.os_versions,
|
||
});
|
||
|
||
const {
|
||
data: hostsData,
|
||
error: errorHosts,
|
||
isFetching: isLoadingHosts,
|
||
refetch: refetchHostsAPI,
|
||
} = useQuery<
|
||
ILoadHostsResponse,
|
||
Error,
|
||
ILoadHostsResponse,
|
||
ILoadHostsQueryKey[]
|
||
>(
|
||
[
|
||
{
|
||
scope: "hosts",
|
||
selectedLabels,
|
||
globalFilter: searchQuery,
|
||
sortBy,
|
||
teamId: teamIdForApi,
|
||
policyId,
|
||
policyResponse,
|
||
softwareId,
|
||
softwareTitleId,
|
||
softwareVersionId,
|
||
softwareStatus,
|
||
status,
|
||
mdmId,
|
||
mdmEnrollmentStatus,
|
||
munkiIssueId,
|
||
lowDiskSpaceHosts,
|
||
osVersionId,
|
||
osName,
|
||
osVersion,
|
||
vulnerability,
|
||
page: curPageFromURL || DEFAULT_PAGE_INDEX,
|
||
perPage: DEFAULT_PAGE_SIZE,
|
||
device_mapping: true,
|
||
osSettings: osSettingsStatus,
|
||
diskEncryptionStatus,
|
||
bootstrapPackageStatus,
|
||
macSettingsStatus,
|
||
configProfileStatus,
|
||
configProfileUUID,
|
||
scriptBatchExecutionStatus,
|
||
scriptBatchExecutionId,
|
||
depProfileError: strToBool(depProfileError),
|
||
depAssignProfileResponse,
|
||
},
|
||
],
|
||
({ queryKey }) => hostsAPI.loadHosts(queryKey[0]),
|
||
{
|
||
enabled: isRouteOk,
|
||
keepPreviousData: true,
|
||
staleTime: 10000, // stale time can be adjusted if fresher data is desired
|
||
}
|
||
);
|
||
|
||
const {
|
||
data: totalFilteredHostsCount,
|
||
error: errorHostsCount,
|
||
isFetching: isLoadingHostsCount,
|
||
refetch: refetchHostsCountAPI,
|
||
} = useQuery<IHostsCountResponse, Error, number, IHostsCountQueryKey[]>(
|
||
[
|
||
{
|
||
scope: "hosts_count",
|
||
selectedLabels,
|
||
globalFilter: searchQuery,
|
||
teamId: teamIdForApi,
|
||
policyId,
|
||
policyResponse,
|
||
softwareId,
|
||
softwareTitleId,
|
||
softwareVersionId,
|
||
softwareStatus,
|
||
status,
|
||
mdmId,
|
||
mdmEnrollmentStatus,
|
||
munkiIssueId,
|
||
lowDiskSpaceHosts,
|
||
osVersionId,
|
||
osName,
|
||
osVersion,
|
||
vulnerability,
|
||
osSettings: osSettingsStatus,
|
||
diskEncryptionStatus,
|
||
bootstrapPackageStatus,
|
||
macSettingsStatus,
|
||
configProfileStatus,
|
||
configProfileUUID,
|
||
scriptBatchExecutionStatus,
|
||
scriptBatchExecutionId,
|
||
},
|
||
],
|
||
({ queryKey }) => hostCountAPI.load(queryKey[0]),
|
||
{
|
||
enabled: isRouteOk,
|
||
keepPreviousData: true,
|
||
staleTime: 10000, // stale time can be adjusted if fresher data is desired
|
||
select: (data) => data.count,
|
||
}
|
||
);
|
||
|
||
// migrate users with current local storage based solution to db persistence
|
||
const locallyHiddenCols = localStorage.getItem("hostHiddenColumns");
|
||
if (locallyHiddenCols) {
|
||
console.log("found local hidden columns: ", locallyHiddenCols);
|
||
console.log("migrating to server persistence...");
|
||
(async () => {
|
||
if (!currentUser) {
|
||
// for type checker
|
||
return;
|
||
}
|
||
const parsed = JSON.parse(locallyHiddenCols) as string[];
|
||
try {
|
||
await usersAPI.update(currentUser.id, {
|
||
settings: { ...userSettings, hidden_host_columns: parsed },
|
||
});
|
||
localStorage.removeItem("hostHiddenColumns");
|
||
} catch {
|
||
// don't remove local storage, proceed with setting context with local storage value
|
||
}
|
||
setHiddenColumns(parsed);
|
||
})();
|
||
}
|
||
|
||
const refetchHosts = () => {
|
||
refetchHostsAPI();
|
||
refetchHostsCountAPI();
|
||
};
|
||
|
||
const hasErrors =
|
||
!!errorHosts ||
|
||
!!errorHostsCount ||
|
||
!!errorPolicy ||
|
||
!!errorConfigProfile ||
|
||
isErrorScriptBatchSummary;
|
||
|
||
const toggleDeleteSecretModal = () => {
|
||
// open and closes delete modal
|
||
setShowDeleteSecretModal(!showDeleteSecretModal);
|
||
// open and closes main enroll secret modal
|
||
setShowEnrollSecretModal(!showEnrollSecretModal);
|
||
};
|
||
|
||
const toggleSecretEditorModal = () => {
|
||
// open and closes add/edit modal
|
||
setShowSecretEditorModal(!showSecretEditorModal);
|
||
// open and closes main enroll secret modall
|
||
setShowEnrollSecretModal(!showEnrollSecretModal);
|
||
};
|
||
|
||
const toggleDeleteLabelModal = () => {
|
||
setShowDeleteLabelModal(!showDeleteLabelModal);
|
||
};
|
||
|
||
const toggleTransferHostModal = () => {
|
||
setShowTransferHostModal(!showTransferHostModal);
|
||
};
|
||
|
||
const toggleDeleteHostModal = () => {
|
||
setShowDeleteHostModal(!showDeleteHostModal);
|
||
};
|
||
|
||
const toggleAddHostsModal = () => {
|
||
setShowAddHostsModal(!showAddHostsModal);
|
||
};
|
||
|
||
const toggleRunScriptBatchModal = useCallback(() => {
|
||
setShowRunScriptBatchModal(!showRunScriptBatchModal);
|
||
}, [showRunScriptBatchModal]);
|
||
|
||
const toggleEditColumnsModal = () => {
|
||
setShowEditColumnsModal(!showEditColumnsModal);
|
||
};
|
||
|
||
const toggleAllMatchingHosts = (shouldSelect: boolean) => {
|
||
if (typeof shouldSelect !== "undefined") {
|
||
setIsAllMatchingHostsSelected(shouldSelect);
|
||
} else {
|
||
setIsAllMatchingHostsSelected(!isAllMatchingHostsSelected);
|
||
}
|
||
};
|
||
|
||
// TODO: cleanup this effect
|
||
useEffect(() => {
|
||
const slugToFind =
|
||
(selectedLabels.length > 0 &&
|
||
selectedLabels.find((f) => f.includes(LABEL_SLUG_PREFIX))) ||
|
||
selectedLabels[0];
|
||
const validLabel = find(labels, ["slug", slugToFind]) as ILabel;
|
||
if (selectedLabel !== validLabel) {
|
||
setSelectedLabel(validLabel);
|
||
}
|
||
}, [labels, selectedLabels, selectedLabel]);
|
||
|
||
// TODO: cleanup this effect
|
||
useEffect(() => {
|
||
if (
|
||
location.search.match(
|
||
/software_id|software_version_id|software_title_id|software_status/gi
|
||
)
|
||
) {
|
||
// regex matches any of "software_id", "software_version_id", "software_title_id", or "software_status"
|
||
// so we don't set the filtered hosts path in those cases
|
||
return;
|
||
}
|
||
const path = location.pathname + location.search;
|
||
if (filteredHostsPath !== path) {
|
||
setFilteredHostsPath(path);
|
||
}
|
||
}, [filteredHostsPath, location, setFilteredHostsPath]);
|
||
|
||
const isLastPage =
|
||
tableQueryData &&
|
||
!!totalFilteredHostsCount &&
|
||
DEFAULT_PAGE_SIZE * tableQueryData.pageIndex +
|
||
(hostsData?.hosts?.length || 0) >=
|
||
totalFilteredHostsCount;
|
||
|
||
const handleLabelChange = ({ slug, id: newLabelId }: ILabel): boolean => {
|
||
const { MANAGE_HOSTS } = PATHS;
|
||
|
||
const isDeselectingLabel = newLabelId && newLabelId === selectedLabel?.id;
|
||
|
||
let newQueryParams = queryParams;
|
||
if (slug) {
|
||
// some filters are incompatible with non-status labels so omit those params from next location
|
||
newQueryParams = omit(
|
||
newQueryParams,
|
||
MANAGE_HOSTS_PAGE_LABEL_INCOMPATIBLE_QUERY_PARAMS
|
||
);
|
||
}
|
||
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: isDeselectingLabel
|
||
? MANAGE_HOSTS
|
||
: `${MANAGE_HOSTS}/${slug}`,
|
||
queryParams: newQueryParams,
|
||
})
|
||
);
|
||
|
||
return true;
|
||
};
|
||
|
||
const handleChangePoliciesFilter = (response: PolicyResponse) => {
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams,
|
||
queryParams: {
|
||
...queryParams,
|
||
policy_id: policyId,
|
||
policy_response: response,
|
||
page: 0, // resets page index
|
||
},
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleChangeDiskEncryptionStatusFilter = (
|
||
newStatus: DiskEncryptionStatus
|
||
) => {
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams,
|
||
queryParams: {
|
||
...queryParams,
|
||
[PARAMS.DISK_ENCRYPTION]: newStatus,
|
||
page: 0, // resets page index
|
||
},
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleChangeOsSettingsFilter = (newStatus: MdmProfileStatus) => {
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams,
|
||
queryParams: {
|
||
...queryParams,
|
||
[PARAMS.OS_SETTINGS]: newStatus,
|
||
page: 0, // resets page index
|
||
},
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleChangeBootstrapPackageStatusFilter = (
|
||
newStatus: BootstrapPackageStatus
|
||
) => {
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams,
|
||
queryParams: { ...queryParams, bootstrap_package: newStatus },
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleClearRouteParam = () => {
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams: undefined,
|
||
queryParams: {
|
||
...queryParams,
|
||
page: 0, // resets page index
|
||
},
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleClearFilter = (omitParams: string[]) => {
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams,
|
||
queryParams: {
|
||
...omit(queryParams, omitParams),
|
||
page: 0, // resets page index
|
||
},
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleStatusDropdownChange = (
|
||
statusName: SingleValue<CustomOptionType>
|
||
) => {
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams,
|
||
queryParams: {
|
||
...queryParams,
|
||
status: statusName?.value,
|
||
page: 0, // resets page index
|
||
},
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleMacSettingsStatusDropdownChange = (
|
||
newMacSettingsStatus: MacSettingsStatusQueryParam
|
||
) => {
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams,
|
||
queryParams: {
|
||
...queryParams,
|
||
macos_settings: newMacSettingsStatus,
|
||
page: 0, // resets page index
|
||
},
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleSoftwareInstallStatusChange = (
|
||
newStatus: SoftwareAggregateStatus
|
||
) => {
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams,
|
||
queryParams: {
|
||
...queryParams,
|
||
[HOSTS_QUERY_PARAMS.SOFTWARE_STATUS]: newStatus,
|
||
page: 0, // resets page index
|
||
},
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleConfigProfileStatusChange = (newStatus: string) => {
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams,
|
||
queryParams: {
|
||
...queryParams,
|
||
profile_status: newStatus,
|
||
profile_uuid: configProfileUUID,
|
||
page: 0, // resets page index
|
||
},
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleChangeScriptBatchStatusFilter = (
|
||
newStatus: ScriptBatchHostCountV1
|
||
) => {
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams,
|
||
queryParams: {
|
||
...queryParams,
|
||
[HOSTS_QUERY_PARAMS.SCRIPT_BATCH_EXECUTION_STATUS]: newStatus,
|
||
page: 0, // resets page index
|
||
},
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleRowSelect = (row: IRowProps) => {
|
||
if (row.original.id) {
|
||
const path = PATHS.HOST_DETAILS(row.original.id);
|
||
|
||
router.push(path);
|
||
}
|
||
};
|
||
|
||
const onAddLabelClick = () => {
|
||
router.push(`${PATHS.NEW_LABEL}`);
|
||
};
|
||
|
||
const onEditLabelClick = (evt: React.MouseEvent<HTMLButtonElement>) => {
|
||
evt.preventDefault();
|
||
router.push(`${PATHS.EDIT_LABEL(parseInt(labelID, 10))}`);
|
||
};
|
||
|
||
const onSaveColumns = async (newHiddenColumns: string[]) => {
|
||
if (!currentUser) {
|
||
return;
|
||
}
|
||
try {
|
||
await usersAPI.update(currentUser.id, {
|
||
settings: { ...userSettings, hidden_host_columns: newHiddenColumns },
|
||
});
|
||
// No success renderFlash, to make column setting more seamless
|
||
// only set state and close modal if server persist succeeds, keeping UI and server state in
|
||
// sync.
|
||
// Can also add local storage fallback behavior in next iteration if we want.
|
||
setHiddenColumns(newHiddenColumns);
|
||
setShowEditColumnsModal(false);
|
||
} catch (response) {
|
||
renderFlash("error", "Couldn't save column settings. Please try again.");
|
||
}
|
||
};
|
||
|
||
// NOTE: this is called once on initial render and every time the query changes
|
||
const onTableQueryChange = useCallback(
|
||
async (newTableQuery: ITableQueryData) => {
|
||
if (!isRouteOk || isEqual(newTableQuery, tableQueryData)) {
|
||
return;
|
||
}
|
||
|
||
setTableQueryData({ ...newTableQuery });
|
||
|
||
const {
|
||
searchQuery: searchText,
|
||
sortHeader,
|
||
sortDirection,
|
||
pageIndex,
|
||
} = newTableQuery;
|
||
|
||
let sort = sortBy;
|
||
if (sortHeader) {
|
||
let direction = sortDirection;
|
||
if (sortHeader === "last_restarted_at") {
|
||
if (sortDirection === "asc") {
|
||
direction = "desc";
|
||
} else {
|
||
direction = "asc";
|
||
}
|
||
}
|
||
sort = [
|
||
{
|
||
key: sortHeader,
|
||
direction: direction || DEFAULT_SORT_DIRECTION,
|
||
},
|
||
];
|
||
} else if (!sortBy.length) {
|
||
sort = [
|
||
{ key: DEFAULT_SORT_HEADER, direction: DEFAULT_SORT_DIRECTION },
|
||
];
|
||
}
|
||
|
||
if (!isEqual(sort, sortBy)) {
|
||
setSortBy([...sort]);
|
||
}
|
||
|
||
if (!isEqual(searchText, searchQuery)) {
|
||
setSearchQuery(searchText);
|
||
}
|
||
|
||
// Rebuild queryParams to dispatch new browser location to react-router
|
||
const newQueryParams: { [key: string]: string | number | undefined } = {};
|
||
if (!isEmpty(searchText)) {
|
||
newQueryParams.query = searchText;
|
||
}
|
||
newQueryParams.page = pageIndex;
|
||
newQueryParams.order_key = sort[0].key || DEFAULT_SORT_HEADER;
|
||
newQueryParams.order_direction =
|
||
sort[0].direction || DEFAULT_SORT_DIRECTION;
|
||
|
||
newQueryParams.fleet_id = teamIdForApi;
|
||
|
||
if (status) {
|
||
newQueryParams.status = status;
|
||
}
|
||
if (policyId && policyResponse) {
|
||
newQueryParams.policy_id = policyId;
|
||
newQueryParams.policy_response = policyResponse;
|
||
} else if (macSettingsStatus) {
|
||
newQueryParams.macos_settings = macSettingsStatus;
|
||
} else if (softwareId) {
|
||
newQueryParams.software_id = softwareId;
|
||
} else if (softwareVersionId) {
|
||
newQueryParams.software_version_id = softwareVersionId;
|
||
// Software version can be combined with os name and os version
|
||
// e.g. Kernel version 6.8.0-71.71 (software version) on Ubuntu 24.04.2LTS (os name and os version)
|
||
if (osVersionId || (osName && osVersion)) {
|
||
newQueryParams.os_version_id = osVersionId;
|
||
newQueryParams.os_name = osName;
|
||
newQueryParams.os_version = osVersion;
|
||
}
|
||
} else if (softwareTitleId) {
|
||
newQueryParams.software_title_id = softwareTitleId;
|
||
if (softwareStatus && teamIdForApi !== API_ALL_TEAMS_ID) {
|
||
// software_status is only valid when software_title_id is present and a subset of hosts ('No team' or a team) is selected
|
||
newQueryParams[HOSTS_QUERY_PARAMS.SOFTWARE_STATUS] = softwareStatus;
|
||
}
|
||
} else if (mdmId) {
|
||
newQueryParams.mdm_id = mdmId;
|
||
} else if (mdmEnrollmentStatus) {
|
||
newQueryParams.mdm_enrollment_status = mdmEnrollmentStatus;
|
||
} else if (munkiIssueId) {
|
||
newQueryParams.munki_issue_id = munkiIssueId;
|
||
} else if (missingHosts) {
|
||
// Premium feature only
|
||
newQueryParams.status = "missing";
|
||
} else if (lowDiskSpaceHosts && isPremiumTier) {
|
||
// Premium feature only
|
||
newQueryParams.low_disk_space = lowDiskSpaceHosts;
|
||
} else if (osVersionId || (osName && osVersion)) {
|
||
newQueryParams.os_version_id = osVersionId;
|
||
newQueryParams.os_name = osName;
|
||
newQueryParams.os_version = osVersion;
|
||
} else if (vulnerability) {
|
||
newQueryParams.vulnerability = vulnerability;
|
||
} else if (osSettingsStatus) {
|
||
newQueryParams[PARAMS.OS_SETTINGS] = osSettingsStatus;
|
||
} else if (diskEncryptionStatus && isPremiumTier) {
|
||
// Premium feature only
|
||
newQueryParams[PARAMS.DISK_ENCRYPTION] = diskEncryptionStatus;
|
||
} else if (bootstrapPackageStatus && isPremiumTier) {
|
||
newQueryParams.bootstrap_package = bootstrapPackageStatus;
|
||
} else if (configProfileStatus && configProfileUUID) {
|
||
newQueryParams.profile_status = configProfileStatus;
|
||
newQueryParams.profile_uuid = configProfileUUID;
|
||
} else if (scriptBatchExecutionStatus && scriptBatchExecutionId) {
|
||
newQueryParams[
|
||
HOSTS_QUERY_PARAMS.SCRIPT_BATCH_EXECUTION_STATUS
|
||
] = scriptBatchExecutionStatus;
|
||
newQueryParams[
|
||
HOSTS_QUERY_PARAMS.SCRIPT_BATCH_EXECUTION_ID
|
||
] = scriptBatchExecutionId;
|
||
} else if (depProfileError) {
|
||
newQueryParams.dep_profile_error = depProfileError;
|
||
} else if (depAssignProfileResponse) {
|
||
newQueryParams.dep_assign_profile_response = depAssignProfileResponse;
|
||
}
|
||
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams,
|
||
queryParams: newQueryParams,
|
||
})
|
||
);
|
||
},
|
||
[
|
||
isRouteOk,
|
||
tableQueryData,
|
||
sortBy,
|
||
searchQuery,
|
||
teamIdForApi,
|
||
status,
|
||
policyId,
|
||
policyResponse,
|
||
macSettingsStatus,
|
||
softwareId,
|
||
softwareVersionId,
|
||
softwareTitleId,
|
||
mdmId,
|
||
mdmEnrollmentStatus,
|
||
munkiIssueId,
|
||
missingHosts,
|
||
lowDiskSpaceHosts,
|
||
isPremiumTier,
|
||
osVersionId,
|
||
osName,
|
||
osVersion,
|
||
vulnerability,
|
||
osSettingsStatus,
|
||
diskEncryptionStatus,
|
||
bootstrapPackageStatus,
|
||
configProfileStatus,
|
||
configProfileUUID,
|
||
scriptBatchExecutionStatus,
|
||
scriptBatchExecutionId,
|
||
router,
|
||
routeTemplate,
|
||
routeParams,
|
||
softwareStatus,
|
||
depProfileError,
|
||
depAssignProfileResponse,
|
||
]
|
||
);
|
||
|
||
const onTeamChange = useCallback(
|
||
(teamId: number) => {
|
||
// TODO(sarah): refactor so that this doesn't trigger two api calls (reset page index updates
|
||
// tableQueryData)
|
||
handleTeamChange(teamId);
|
||
|
||
// Must clear other page paths or the team might accidentally switch
|
||
// When navigating from host details
|
||
setFilteredSoftwarePath("");
|
||
setFilteredQueriesPath("");
|
||
setFilteredPoliciesPath("");
|
||
},
|
||
[handleTeamChange]
|
||
);
|
||
|
||
const onSaveSecret = async (enrollSecretString: string) => {
|
||
const { MANAGE_HOSTS } = PATHS;
|
||
|
||
// Creates new list of secrets removing selected secret and adding new secret
|
||
const currentSecrets = isAnyTeamSelected
|
||
? teamSecrets || []
|
||
: globalSecrets || [];
|
||
|
||
const newSecrets = currentSecrets.filter(
|
||
(s) => s.secret !== selectedSecret?.secret
|
||
);
|
||
|
||
if (enrollSecretString) {
|
||
newSecrets.push({ secret: enrollSecretString });
|
||
}
|
||
|
||
setIsUpdating(true);
|
||
|
||
try {
|
||
if (isAnyTeamSelected) {
|
||
await enrollSecretsAPI.modifyTeamEnrollSecrets(
|
||
currentTeamId,
|
||
newSecrets
|
||
);
|
||
refetchTeamSecrets();
|
||
} else {
|
||
await enrollSecretsAPI.modifyGlobalEnrollSecrets(newSecrets);
|
||
refetchGlobalSecrets();
|
||
}
|
||
toggleSecretEditorModal();
|
||
isPremiumTier && refetchTeams();
|
||
|
||
router.push(
|
||
getNextLocationPath({
|
||
pathPrefix: MANAGE_HOSTS,
|
||
routeTemplate: routeTemplate.replace("/labels/:label_id", ""),
|
||
routeParams,
|
||
queryParams,
|
||
})
|
||
);
|
||
renderFlash(
|
||
"success",
|
||
`Successfully ${selectedSecret ? "edited" : "added"} enroll secret.`
|
||
);
|
||
} catch (error) {
|
||
console.error(error);
|
||
renderFlash(
|
||
"error",
|
||
`Could not ${
|
||
selectedSecret ? "edit" : "add"
|
||
} enroll secret. Please try again.`
|
||
);
|
||
} finally {
|
||
setIsUpdating(false);
|
||
}
|
||
};
|
||
|
||
const onDeleteSecret = async () => {
|
||
const { MANAGE_HOSTS } = PATHS;
|
||
|
||
// create new list of secrets removing selected secret
|
||
const currentSecrets = isAnyTeamSelected
|
||
? teamSecrets || []
|
||
: globalSecrets || [];
|
||
|
||
const newSecrets = currentSecrets.filter(
|
||
(s) => s.secret !== selectedSecret?.secret
|
||
);
|
||
|
||
setIsUpdating(true);
|
||
|
||
try {
|
||
if (isAnyTeamSelected) {
|
||
await enrollSecretsAPI.modifyTeamEnrollSecrets(
|
||
currentTeamId,
|
||
newSecrets
|
||
);
|
||
refetchTeamSecrets();
|
||
} else {
|
||
await enrollSecretsAPI.modifyGlobalEnrollSecrets(newSecrets);
|
||
refetchGlobalSecrets();
|
||
}
|
||
toggleDeleteSecretModal();
|
||
refetchTeams();
|
||
router.push(
|
||
getNextLocationPath({
|
||
pathPrefix: MANAGE_HOSTS,
|
||
routeTemplate: routeTemplate.replace("/labels/:label_id", ""),
|
||
routeParams,
|
||
queryParams,
|
||
})
|
||
);
|
||
renderFlash("success", `Successfully deleted enroll secret.`);
|
||
} catch (error) {
|
||
console.error(error);
|
||
renderFlash("error", "Could not delete enroll secret. Please try again.");
|
||
} finally {
|
||
setIsUpdating(false);
|
||
}
|
||
};
|
||
|
||
const onDeleteLabel = async () => {
|
||
if (!selectedLabel) {
|
||
console.error("Label isn't available. This should not happen.");
|
||
return false;
|
||
}
|
||
setIsUpdating(true);
|
||
|
||
const { MANAGE_HOSTS } = PATHS;
|
||
try {
|
||
await labelsAPI.destroy(selectedLabel);
|
||
toggleDeleteLabelModal();
|
||
refetchLabels();
|
||
|
||
router.push(
|
||
getNextLocationPath({
|
||
pathPrefix: MANAGE_HOSTS,
|
||
routeTemplate: routeTemplate.replace("/labels/:label_id", ""),
|
||
routeParams,
|
||
queryParams,
|
||
})
|
||
);
|
||
renderFlash("success", "Successfully deleted label.");
|
||
} catch (error) {
|
||
renderFlash("error", getDeleteLabelErrorMessages(error));
|
||
} finally {
|
||
setIsUpdating(false);
|
||
}
|
||
};
|
||
|
||
const onTransferToTeamClick = (hostIds: number[]) => {
|
||
toggleTransferHostModal();
|
||
setSelectedHostIds(hostIds);
|
||
};
|
||
|
||
const onClickRunScriptBatchAction = (hostIds: number[]) => {
|
||
setSelectedHostIds(hostIds);
|
||
toggleRunScriptBatchModal();
|
||
};
|
||
|
||
const onDeleteHostsClick = (hostIds: number[]) => {
|
||
toggleDeleteHostModal();
|
||
setSelectedHostIds(hostIds);
|
||
};
|
||
|
||
// Bulk transfer is hidden for defined unsupportedFilters
|
||
const onTransferHostSubmit = async (transferTeam: ITeam) => {
|
||
setIsUpdating(true);
|
||
|
||
const teamId = typeof transferTeam.id === "number" ? transferTeam.id : null;
|
||
|
||
const action = isAllMatchingHostsSelected
|
||
? hostsAPI.transferToTeamByFilter({
|
||
teamId,
|
||
query: searchQuery,
|
||
status,
|
||
labelId: selectedLabel?.id,
|
||
currentTeam: teamIdForApi,
|
||
policyId,
|
||
policyResponse,
|
||
softwareId,
|
||
softwareTitleId,
|
||
softwareVersionId,
|
||
softwareStatus,
|
||
osName,
|
||
osVersionId,
|
||
osVersion,
|
||
macSettingsStatus,
|
||
bootstrapPackageStatus,
|
||
mdmId,
|
||
mdmEnrollmentStatus,
|
||
munkiIssueId,
|
||
lowDiskSpaceHosts,
|
||
osSettings: osSettingsStatus,
|
||
diskEncryptionStatus,
|
||
vulnerability,
|
||
depProfileError,
|
||
depAssignProfileResponse,
|
||
})
|
||
: hostsAPI.transferToTeam(teamId, selectedHostIds);
|
||
|
||
try {
|
||
await action;
|
||
|
||
const successMessage =
|
||
teamId === null
|
||
? `Hosts successfully removed from fleets.`
|
||
: `Hosts successfully transferred to ${transferTeam.name}.`;
|
||
|
||
renderFlash("success", successMessage);
|
||
setResetSelectedRows(true);
|
||
refetchHosts();
|
||
toggleTransferHostModal();
|
||
setSelectedHostIds([]);
|
||
setIsAllMatchingHostsSelected(false);
|
||
} catch (error) {
|
||
renderFlash("error", "Could not transfer hosts. Please try again.");
|
||
} finally {
|
||
setIsUpdating(false);
|
||
}
|
||
};
|
||
|
||
// Bulk delete is hidden for defined unsupportedFilters
|
||
const onDeleteHostSubmit = async () => {
|
||
setIsUpdating(true);
|
||
|
||
try {
|
||
await (isAllMatchingHostsSelected
|
||
? hostsAPI.destroyByFilter({
|
||
teamId: teamIdForApi,
|
||
query: searchQuery,
|
||
status,
|
||
labelId: selectedLabel?.id,
|
||
policyId,
|
||
policyResponse,
|
||
softwareId,
|
||
softwareTitleId,
|
||
softwareVersionId,
|
||
softwareStatus,
|
||
osName,
|
||
osVersionId,
|
||
osVersion,
|
||
macSettingsStatus,
|
||
bootstrapPackageStatus,
|
||
mdmId,
|
||
mdmEnrollmentStatus,
|
||
munkiIssueId,
|
||
lowDiskSpaceHosts,
|
||
osSettings: osSettingsStatus,
|
||
diskEncryptionStatus,
|
||
vulnerability,
|
||
})
|
||
: hostsAPI.destroyBulk(selectedHostIds));
|
||
|
||
const successMessage = `${
|
||
selectedHostIds.length === 1 ? "Host" : "Hosts"
|
||
} successfully deleted.`;
|
||
|
||
renderFlash("success", successMessage);
|
||
setResetSelectedRows(true);
|
||
refetchHosts();
|
||
refetchLabels();
|
||
toggleDeleteHostModal();
|
||
setSelectedHostIds([]);
|
||
setIsAllMatchingHostsSelected(false);
|
||
} catch (error) {
|
||
renderFlash(
|
||
"error",
|
||
`Could not delete ${
|
||
selectedHostIds.length === 1 ? "host" : "hosts"
|
||
}. Please try again.`
|
||
);
|
||
} finally {
|
||
setIsUpdating(false);
|
||
}
|
||
};
|
||
|
||
const renderEditColumnsModal = () => {
|
||
if (!config || !currentUser) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<EditColumnsModal
|
||
columns={generateAvailableTableHeaders({ isFreeTier, isOnlyObserver })}
|
||
hiddenColumns={hiddenColumns}
|
||
onSaveColumns={onSaveColumns}
|
||
onCancelColumns={toggleEditColumnsModal}
|
||
/>
|
||
);
|
||
};
|
||
|
||
const renderSecretEditorModal = () => (
|
||
<SecretEditorModal
|
||
selectedTeam={teamIdForApi || 0}
|
||
primoMode={isPrimoMode || false}
|
||
teams={teams || []}
|
||
onSaveSecret={onSaveSecret}
|
||
toggleSecretEditorModal={toggleSecretEditorModal}
|
||
selectedSecret={selectedSecret}
|
||
isUpdatingSecret={isUpdating}
|
||
/>
|
||
);
|
||
|
||
const renderDeleteSecretModal = () => (
|
||
<DeleteSecretModal
|
||
onDeleteSecret={onDeleteSecret}
|
||
toggleDeleteSecretModal={toggleDeleteSecretModal}
|
||
isUpdatingSecret={isUpdating}
|
||
/>
|
||
);
|
||
|
||
const renderEnrollSecretModal = () => (
|
||
<EnrollSecretModal
|
||
selectedTeamId={teamIdForApi || 0}
|
||
primoMode={isPrimoMode || false}
|
||
teams={teams || []}
|
||
onReturnToApp={() => setShowEnrollSecretModal(false)}
|
||
toggleSecretEditorModal={toggleSecretEditorModal}
|
||
toggleDeleteSecretModal={toggleDeleteSecretModal}
|
||
setSelectedSecret={setSelectedSecret}
|
||
globalSecrets={globalSecrets}
|
||
/>
|
||
);
|
||
|
||
const renderDeleteLabelModal = () => (
|
||
<DeleteLabelModal
|
||
onSubmit={onDeleteLabel}
|
||
onCancel={toggleDeleteLabelModal}
|
||
isUpdatingLabel={isUpdating}
|
||
/>
|
||
);
|
||
|
||
const renderAddHostsModal = () => {
|
||
const enrollSecret = isAnyTeamSelected
|
||
? teamSecrets?.[0].secret
|
||
: globalSecrets?.[0].secret;
|
||
return (
|
||
<AddHostsModal
|
||
currentTeamName={currentTeamName || "Fleet"}
|
||
enrollSecret={enrollSecret}
|
||
isAnyTeamSelected={isAnyTeamSelected}
|
||
isLoading={isLoadingTeams || isGlobalSecretsLoading}
|
||
onCancel={toggleAddHostsModal}
|
||
openEnrollSecretModal={() => setShowEnrollSecretModal(true)}
|
||
/>
|
||
);
|
||
};
|
||
|
||
const renderTransferHostModal = () => {
|
||
if (!teams) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<TransferHostModal
|
||
isGlobalAdmin={isGlobalAdmin as boolean}
|
||
teams={teams}
|
||
onSubmit={onTransferHostSubmit}
|
||
onCancel={toggleTransferHostModal}
|
||
isUpdating={isUpdating}
|
||
multipleHosts={selectedHostIds.length > 1}
|
||
hostsTeamId={currentTeamId} // Removes current team from the transfer options
|
||
/>
|
||
);
|
||
};
|
||
|
||
const renderDeleteHostModal = () => (
|
||
<DeleteHostModal
|
||
selectedHostIds={selectedHostIds}
|
||
onSubmit={onDeleteHostSubmit}
|
||
onCancel={toggleDeleteHostModal}
|
||
isAllMatchingHostsSelected={isAllMatchingHostsSelected}
|
||
hostsCount={totalFilteredHostsCount}
|
||
isUpdating={isUpdating}
|
||
/>
|
||
);
|
||
|
||
const renderHeaderContent = () => {
|
||
if (isPremiumTier && !isPrimoMode && userTeams) {
|
||
if (userTeams.length > 1 || isOnGlobalTeam) {
|
||
return (
|
||
<TeamsDropdown
|
||
currentUserTeams={userTeams || []}
|
||
selectedTeamId={currentTeamId}
|
||
onChange={onTeamChange}
|
||
includeNoTeams
|
||
/>
|
||
);
|
||
}
|
||
if (!isOnGlobalTeam && userTeams.length === 1) {
|
||
return <h1>{userTeams[0].name}</h1>;
|
||
}
|
||
}
|
||
return <h1>Hosts</h1>;
|
||
};
|
||
|
||
const renderHeader = () => (
|
||
<div className={`${baseClass}__header`}>
|
||
<div className={`${baseClass}__text`}>
|
||
<div className={`${baseClass}__title`}>{renderHeaderContent()}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const onExportHostsResults = async (
|
||
evt: React.MouseEvent<HTMLButtonElement>
|
||
) => {
|
||
evt.preventDefault();
|
||
|
||
const hiddenColumnsStorage = localStorage.getItem("hostHiddenColumns");
|
||
let currentHiddenColumns = [];
|
||
let visibleColumns;
|
||
if (hiddenColumnsStorage) {
|
||
currentHiddenColumns = JSON.parse(hiddenColumnsStorage);
|
||
}
|
||
|
||
if (config && currentUser) {
|
||
const tableColumns = generateVisibleTableColumns({
|
||
hiddenColumns: currentHiddenColumns,
|
||
isFreeTier,
|
||
isOnlyObserver,
|
||
teamId: teamIdForApi,
|
||
});
|
||
|
||
const columnIds = tableColumns
|
||
.map((column) => (column.id ? column.id : ""))
|
||
// "selection" colum does not include any relevent data for the CSV
|
||
// so we filter it out.
|
||
.filter((element) => element !== "" && element !== "selection");
|
||
visibleColumns = columnIds.join(",");
|
||
}
|
||
|
||
let options = {
|
||
selectedLabels,
|
||
globalFilter: searchQuery,
|
||
sortBy,
|
||
teamId: teamIdForApi,
|
||
policyId,
|
||
policyResponse,
|
||
macSettingsStatus,
|
||
softwareId,
|
||
softwareTitleId,
|
||
softwareVersionId,
|
||
softwareStatus,
|
||
status,
|
||
mdmId,
|
||
mdmEnrollmentStatus,
|
||
munkiIssueId,
|
||
lowDiskSpaceHosts,
|
||
osName,
|
||
osVersionId,
|
||
osVersion,
|
||
osSettings: osSettingsStatus,
|
||
bootstrapPackageStatus,
|
||
vulnerability,
|
||
visibleColumns,
|
||
configProfileUUID,
|
||
configProfileStatus,
|
||
scriptBatchExecutionStatus,
|
||
scriptBatchExecutionId,
|
||
};
|
||
|
||
options = {
|
||
...options,
|
||
teamId: teamIdForApi,
|
||
};
|
||
|
||
if (
|
||
queryParams.fleet_id !== API_ALL_TEAMS_ID &&
|
||
queryParams.fleet_id !== ""
|
||
) {
|
||
options.teamId = queryParams.fleet_id;
|
||
}
|
||
|
||
try {
|
||
const exportHostResults = await hostsAPI.exportHosts(options);
|
||
|
||
const formattedTime = format(new Date(), "yyyy-MM-dd");
|
||
const filename = `${CSV_HOSTS_TITLE} ${formattedTime}.csv`;
|
||
const file = new global.window.File([exportHostResults], filename, {
|
||
type: "text/csv",
|
||
});
|
||
|
||
FileSaver.saveAs(file);
|
||
} catch (error) {
|
||
console.error(error);
|
||
renderFlash("error", "Could not export hosts. Please try again.");
|
||
}
|
||
};
|
||
|
||
const renderHostCount = useCallback(() => {
|
||
return (
|
||
<>
|
||
<TableCount name="hosts" count={totalFilteredHostsCount} />
|
||
{!!totalFilteredHostsCount && (
|
||
<Button
|
||
className={`${baseClass}__export-btn`}
|
||
onClick={onExportHostsResults}
|
||
variant="inverse"
|
||
>
|
||
<>
|
||
Export hosts
|
||
<Icon name="download" size="small" />
|
||
</>
|
||
</Button>
|
||
)}
|
||
</>
|
||
);
|
||
}, [isLoadingHostsCount, totalFilteredHostsCount]);
|
||
|
||
const renderCustomControls = () => {
|
||
// we filter out the status labels as we dont want to display them in the label
|
||
// filter select dropdown.
|
||
// TODO: seperate labels and status into different data sets.
|
||
const selectedDropdownLabel =
|
||
selectedLabel?.type !== "all" && selectedLabel?.type !== "status"
|
||
? selectedLabel
|
||
: undefined;
|
||
|
||
return (
|
||
<div className={`${baseClass}__filter-dropdowns`}>
|
||
<DropdownWrapper
|
||
name="status-filter"
|
||
value={status || ""}
|
||
className={`${baseClass}__status-filter`}
|
||
options={hostSelectStatuses}
|
||
onChange={handleStatusDropdownChange}
|
||
variant="table-filter"
|
||
/>
|
||
<LabelFilterSelect
|
||
className={`${baseClass}__label-filter-dropdown`}
|
||
labels={labels ?? []}
|
||
canAddNewLabels={canAddNewLabels}
|
||
selectedLabel={selectedDropdownLabel ?? null}
|
||
onChange={handleLabelChange}
|
||
onAddLabel={onAddLabelClick}
|
||
isLoading={isLoadingLabels}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// TODO: try to reduce overlap between maybeEmptyHosts and includesFilterQueryParam
|
||
const maybeEmptyHosts =
|
||
totalFilteredHostsCount === 0 && searchQuery === "" && !labelID && !status;
|
||
|
||
const includesFilterQueryParam = MANAGE_HOSTS_PAGE_FILTER_KEYS.some(
|
||
(filter) =>
|
||
filter !== "fleet_id" &&
|
||
typeof queryParams === "object" &&
|
||
filter in queryParams // TODO: replace this with `Object.hasOwn(queryParams, filter)` when we upgrade to es2022
|
||
);
|
||
|
||
// Ensures rendering table/pills simultaneously when all API calls are done
|
||
const isLoading =
|
||
isLoadingHosts ||
|
||
isLoadingHostsCount ||
|
||
isLoadingPolicy ||
|
||
isLoadingOsVersions ||
|
||
isLoadingConfigProfile ||
|
||
isLoadingScriptBatchSummary;
|
||
|
||
const renderTable = () => {
|
||
if (!config || !currentUser || !isRouteOk) {
|
||
return <Spinner />;
|
||
}
|
||
|
||
if (hasErrors) {
|
||
return <DataError verticalPaddingSize="pad-xxxlarge" />;
|
||
}
|
||
if (maybeEmptyHosts) {
|
||
const emptyState = () => {
|
||
const emptyHosts: IEmptyTableProps = {
|
||
graphicName: "empty-hosts",
|
||
header: "Hosts will show up here once they’re added to Fleet",
|
||
info:
|
||
"Expecting to see hosts? Try again soon as the system catches up.",
|
||
};
|
||
if (includesFilterQueryParam) {
|
||
delete emptyHosts.graphicName;
|
||
emptyHosts.header = "No hosts match the current criteria";
|
||
emptyHosts.info =
|
||
"Expecting to see new hosts? Try again soon as the system catches up.";
|
||
} else if (canEnrollHosts) {
|
||
emptyHosts.header = "Add your hosts to Fleet";
|
||
emptyHosts.info =
|
||
"Generate Fleet's agent (fleetd) to add your own hosts.";
|
||
emptyHosts.primaryButton = (
|
||
<Button onClick={toggleAddHostsModal} type="button">
|
||
Add hosts
|
||
</Button>
|
||
);
|
||
}
|
||
return emptyHosts;
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{EmptyTable({
|
||
graphicName: emptyState().graphicName,
|
||
header: emptyState().header,
|
||
info: emptyState().info,
|
||
additionalInfo: emptyState().additionalInfo,
|
||
primaryButton: emptyState().primaryButton,
|
||
})}
|
||
</>
|
||
);
|
||
}
|
||
|
||
let disableRunScriptBatchTooltipContent: React.ReactNode;
|
||
if (config?.server_settings?.scripts_disabled) {
|
||
disableRunScriptBatchTooltipContent = (
|
||
<>
|
||
Running scripts is disabled in <br />
|
||
organization settings.
|
||
</>
|
||
);
|
||
} else if (isAllTeamsSelected && isPremiumTier) {
|
||
disableRunScriptBatchTooltipContent = "Select a fleet to run a script";
|
||
} else if (isAllMatchingHostsSelected) {
|
||
if (runScriptBatchFilterNotSupported) {
|
||
disableRunScriptBatchTooltipContent =
|
||
"Choose different filters to run a script";
|
||
} else if (
|
||
// default to blocking until count API responds
|
||
!totalFilteredHostsCount ||
|
||
totalFilteredHostsCount > MAX_SCRIPT_BATCH_TARGETS
|
||
) {
|
||
disableRunScriptBatchTooltipContent =
|
||
"Target at most 5,000 hosts to run a script";
|
||
}
|
||
}
|
||
const secondarySelectActions: IActionButtonProps[] = [
|
||
{
|
||
name: "run-script",
|
||
onClick: onClickRunScriptBatchAction,
|
||
buttonText: "Run script",
|
||
variant: "inverse",
|
||
iconSvg: "run",
|
||
iconStroke: true,
|
||
hideButton: !canRunScriptBatch,
|
||
isDisabled: !!disableRunScriptBatchTooltipContent,
|
||
tooltipContent: disableRunScriptBatchTooltipContent,
|
||
},
|
||
{
|
||
name: "transfer",
|
||
onClick: onTransferToTeamClick,
|
||
buttonText: "Transfer",
|
||
variant: "inverse",
|
||
iconSvg: "transfer",
|
||
hideButton:
|
||
!isPremiumTier ||
|
||
(!isGlobalAdmin && !isGlobalMaintainer) ||
|
||
isPrimoMode,
|
||
},
|
||
];
|
||
|
||
const tableColumns = generateVisibleTableColumns({
|
||
hiddenColumns,
|
||
isFreeTier,
|
||
isOnlyObserver:
|
||
isOnlyObserver ||
|
||
isGlobalTechnician ||
|
||
(!isOnGlobalTeam && !isTeamMaintainerOrTeamAdmin),
|
||
teamId: teamIdForApi,
|
||
});
|
||
|
||
const emptyState = () => {
|
||
const emptyHosts: IEmptyTableProps = {
|
||
header: "No hosts match the current criteria",
|
||
info:
|
||
"Expecting to see new hosts? Try again soon as the system catches up.",
|
||
};
|
||
if (isLastPage) {
|
||
emptyHosts.header = "No more hosts to display";
|
||
emptyHosts.info =
|
||
"Expecting to see more hosts? Try again soon as the system catches up.";
|
||
}
|
||
|
||
return emptyHosts;
|
||
};
|
||
|
||
// Shortterm fix for #17257
|
||
const unsupportedFilter = !!(
|
||
policyId ||
|
||
policyResponse ||
|
||
softwareId ||
|
||
softwareTitleId ||
|
||
softwareVersionId ||
|
||
osName ||
|
||
osVersionId ||
|
||
osVersion ||
|
||
macSettingsStatus ||
|
||
bootstrapPackageStatus ||
|
||
mdmId ||
|
||
mdmEnrollmentStatus ||
|
||
munkiIssueId ||
|
||
lowDiskSpaceHosts ||
|
||
osSettingsStatus ||
|
||
diskEncryptionStatus ||
|
||
vulnerability ||
|
||
depProfileError ||
|
||
depAssignProfileResponse
|
||
);
|
||
|
||
return (
|
||
<TableContainer
|
||
resultsTitle="hosts"
|
||
columnConfigs={tableColumns}
|
||
data={hostsData?.hosts || []}
|
||
isLoading={isLoading}
|
||
manualSortBy
|
||
defaultSortHeader={(sortBy[0] && sortBy[0].key) || DEFAULT_SORT_HEADER}
|
||
defaultSortDirection={
|
||
(sortBy[0] && sortBy[0].direction) || DEFAULT_SORT_DIRECTION
|
||
}
|
||
pageIndex={curPageFromURL}
|
||
defaultSearchQuery={searchQuery}
|
||
pageSize={DEFAULT_PAGE_SIZE}
|
||
additionalQueries={JSON.stringify(selectedLabels)}
|
||
inputPlaceHolder={HOSTS_SEARCH_BOX_PLACEHOLDER}
|
||
actionButton={{
|
||
name: "edit columns",
|
||
buttonText: "Edit columns",
|
||
iconSvg: "columns",
|
||
variant: "inverse",
|
||
onClick: toggleEditColumnsModal,
|
||
}}
|
||
primarySelectAction={{
|
||
name: "delete host",
|
||
buttonText: "Delete",
|
||
iconSvg: "trash",
|
||
variant: "inverse",
|
||
onClick: onDeleteHostsClick,
|
||
}}
|
||
secondarySelectActions={secondarySelectActions}
|
||
showMarkAllPages={!unsupportedFilter} // Shortterm fix for #17257
|
||
isAllPagesSelected={isAllMatchingHostsSelected}
|
||
searchable
|
||
renderCount={renderHostCount}
|
||
searchToolTipText={HOSTS_SEARCH_BOX_TOOLTIP}
|
||
emptyComponent={() =>
|
||
EmptyTable({
|
||
header: emptyState().header,
|
||
info: emptyState().info,
|
||
})
|
||
}
|
||
customControl={renderCustomControls}
|
||
onQueryChange={onTableQueryChange}
|
||
toggleAllPagesSelected={toggleAllMatchingHosts}
|
||
onClickRow={handleRowSelect}
|
||
disableNextPage={isLastPage}
|
||
/>
|
||
);
|
||
};
|
||
|
||
const renderNoEnrollSecretBanner = () => {
|
||
const noTeamEnrollSecrets =
|
||
isAnyTeamSelected && !isTeamSecretsLoading && !teamSecrets?.length;
|
||
const noGlobalEnrollSecrets =
|
||
(!isPremiumTier ||
|
||
(isPremiumTier && !isAnyTeamSelected && !isLoadingTeams)) &&
|
||
!isGlobalSecretsLoading &&
|
||
!globalSecrets?.length;
|
||
|
||
return (
|
||
((canEnrollHosts && noTeamEnrollSecrets) ||
|
||
(canEnrollGlobalHosts && noGlobalEnrollSecrets)) && (
|
||
<InfoBanner
|
||
className={`${baseClass}__no-enroll-secret-banner`}
|
||
color="yellow"
|
||
>
|
||
<div>
|
||
<span>
|
||
You have no enroll secrets. Manage enroll secrets to enroll hosts
|
||
to <b>{isAnyTeamSelected ? currentTeamName : "Fleet"}</b>.
|
||
</span>
|
||
</div>
|
||
</InfoBanner>
|
||
)
|
||
);
|
||
};
|
||
|
||
const showAddHostsButton =
|
||
canEnrollHosts &&
|
||
!hasErrors &&
|
||
(!maybeEmptyHosts || includesFilterQueryParam);
|
||
|
||
return (
|
||
<>
|
||
<MainContent className={baseClass}>
|
||
<div className={`${baseClass}__header-wrap`}>
|
||
{renderHeader()}
|
||
<div className={`${baseClass}__button-wrap`}>
|
||
{canEnrollHosts && !hasErrors && (
|
||
<Button
|
||
onClick={() => setShowEnrollSecretModal(true)}
|
||
className={`${baseClass}__enroll-hosts button`}
|
||
variant="inverse"
|
||
>
|
||
Manage enroll secret
|
||
</Button>
|
||
)}
|
||
{showAddHostsButton && (
|
||
<Button
|
||
onClick={toggleAddHostsModal}
|
||
className={`${baseClass}__add-hosts`}
|
||
>
|
||
<span>Add hosts</span>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* TODO: look at improving the props API for this component. Im thinking
|
||
some of the props can be defined inside HostsFilterBlock */}
|
||
<HostsFilterBlock
|
||
params={{
|
||
policyResponse,
|
||
policyId,
|
||
policy,
|
||
macSettingsStatus,
|
||
softwareId,
|
||
softwareTitleId,
|
||
softwareVersionId,
|
||
softwareStatus,
|
||
mdmId,
|
||
mdmEnrollmentStatus,
|
||
lowDiskSpaceHosts,
|
||
osVersionId,
|
||
osName,
|
||
osVersion,
|
||
osVersions,
|
||
munkiIssueId,
|
||
munkiIssueDetails: hostsData?.munki_issue || null,
|
||
softwareDetails:
|
||
hostsData?.software || hostsData?.software_title || null,
|
||
mdmSolutionDetails:
|
||
hostsData?.mobile_device_management_solution || null,
|
||
osSettingsStatus,
|
||
diskEncryptionStatus,
|
||
bootstrapPackageStatus,
|
||
vulnerability,
|
||
configProfileStatus,
|
||
configProfileUUID,
|
||
configProfile,
|
||
scriptBatchExecutionStatus,
|
||
scriptBatchExecutionId,
|
||
scriptBatchRanAt: scriptBatchSummary?.created_at || null,
|
||
scriptBatchScriptName: scriptBatchSummary?.script_name || null,
|
||
depProfileError,
|
||
depAssignProfileResponse,
|
||
}}
|
||
selectedLabel={selectedLabel}
|
||
isOnlyObserver={isOnlyObserver}
|
||
handleClearRouteParam={handleClearRouteParam}
|
||
handleClearFilter={handleClearFilter}
|
||
onChangePoliciesFilter={handleChangePoliciesFilter}
|
||
onChangeOsSettingsFilter={handleChangeOsSettingsFilter}
|
||
onChangeDiskEncryptionStatusFilter={
|
||
handleChangeDiskEncryptionStatusFilter
|
||
}
|
||
onChangeBootstrapPackageStatusFilter={
|
||
handleChangeBootstrapPackageStatusFilter
|
||
}
|
||
onChangeMacSettingsFilter={handleMacSettingsStatusDropdownChange}
|
||
onChangeSoftwareInstallStatusFilter={
|
||
handleSoftwareInstallStatusChange
|
||
}
|
||
onChangeConfigProfileStatusFilter={handleConfigProfileStatusChange}
|
||
onChangeScriptBatchStatusFilter={handleChangeScriptBatchStatusFilter}
|
||
onClickEditLabel={onEditLabelClick}
|
||
onClickDeleteLabel={toggleDeleteLabelModal}
|
||
isLoading={isLoading}
|
||
isScriptPackage={SCRIPT_PACKAGE_SOURCES.includes(
|
||
hostsData?.software_title?.source || ""
|
||
)}
|
||
/>
|
||
{renderNoEnrollSecretBanner()}
|
||
{renderTable()}
|
||
</MainContent>
|
||
{canEnrollHosts && showDeleteSecretModal && renderDeleteSecretModal()}
|
||
{canEnrollHosts && showSecretEditorModal && renderSecretEditorModal()}
|
||
{canEnrollHosts && showEnrollSecretModal && renderEnrollSecretModal()}
|
||
{showEditColumnsModal && renderEditColumnsModal()}
|
||
{showDeleteLabelModal && renderDeleteLabelModal()}
|
||
{showAddHostsModal && renderAddHostsModal()}
|
||
{showTransferHostModal && renderTransferHostModal()}
|
||
{showDeleteHostModal && renderDeleteHostModal()}
|
||
{showRunScriptBatchModal &&
|
||
currentTeamId !== undefined &&
|
||
totalFilteredHostsCount !== undefined && (
|
||
<RunScriptBatchModal
|
||
runByFilters={isAllMatchingHostsSelected}
|
||
// run script batch supports only these filters, plus team id
|
||
filters={{
|
||
query: searchQuery || undefined,
|
||
label_id: isNaN(Number(labelID)) ? undefined : Number(labelID),
|
||
status: status || undefined,
|
||
}}
|
||
// when running by filter, modal needs this count to report the number of targeted hosts
|
||
totalFilteredHostsCount={totalFilteredHostsCount}
|
||
// when running by selected hosts, modal can use the length of this array to report the number of targeted
|
||
// hosts
|
||
selectedHostIds={selectedHostIds}
|
||
teamId={currentTeamId}
|
||
isFreeTier={isFreeTier}
|
||
onCancel={toggleRunScriptBatchModal}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default ManageHostsPage;
|