Files
fleet/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx
T

2211 lines
66 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 policiesAPI from "services/entities/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 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 { IEmptyStateProps } from "interfaces/empty_state";
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 { getPathWithQueryParams } from "utilities/url";
import getDeleteLabelErrorMessages from "pages/labels/helpers";
import { strToBool } from "utilities/strings/stringUtils";
import { notify } from "components/ToastNotification";
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 FleetsDropdown from "components/FleetsDropdown";
import Spinner from "components/Spinner";
import MainContent from "components/MainContent";
import EmptyState from "components/EmptyState";
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 { 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;
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 { 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);
// Hoisted above the deep-link effects so they share the same gate
// as the in-page Add hosts / Manage enroll secrets affordances.
const canEnrollHosts =
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
// Open add hosts modal via query param (e.g. from command palette).
// Wait until role flags and the team route have resolved before
// evaluating, so an authorized admin/maintainer landing directly on
// ?add_hosts=1 isn't denied while AppContext is still hydrating.
useEffect(() => {
if (queryParams?.add_hosts !== "1") return;
if (isGlobalAdmin === undefined || !isRouteOk) return;
if (canEnrollHosts) {
setShowAddHostsModal(true);
}
const { add_hosts, ...rest } = queryParams;
router.replace({ pathname: location.pathname, query: rest });
}, [
queryParams,
location.pathname,
router,
canEnrollHosts,
isGlobalAdmin,
isRouteOk,
]);
// Open enroll secrets modal via query param (e.g. from command palette).
// Same hydration gate as the add-hosts effect above.
useEffect(() => {
if (queryParams?.manage_enroll_secrets !== "1") return;
if (isGlobalAdmin === undefined || !isRouteOk) return;
if (canEnrollHosts) {
setShowEnrollSecretModal(true);
}
const { manage_enroll_secrets, ...rest } = queryParams;
router.replace({ pathname: location.pathname, query: rest });
}, [
queryParams,
location.pathname,
router,
canEnrollHosts,
isGlobalAdmin,
isRouteOk,
]);
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?.apple_settings ?? 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?.macos_bootstrap_package ?? 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 { label_id: labelID } = routeParams;
const selectedLabels = useMemo(() => {
const filters: string[] = [];
labelID && filters.push(`${LABEL_SLUG_PREFIX}${labelID}`);
return filters;
}, [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
// canEnrollHosts is hoisted above the deep-link effects (see earlier)
const canEnrollGlobalHosts = isGlobalAdmin || isGlobalMaintainer;
// Mirrors the Controls > Variables > Custom host vitals tab, which only lets
// global admins/maintainers manage vital definitions.
const canManageCustomHostVitals = 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],
() => policiesAPI.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, macos_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>
) => {
const value = statusName?.value;
router.replace(
getNextLocationPath({
pathPrefix: PATHS.MANAGE_HOSTS,
routeTemplate,
routeParams,
queryParams: {
...queryParams,
...(value !== "pending" && {
status: value,
mdm_enrollment_status: undefined,
}),
...(value === "pending" && {
mdm_enrollment_status: value,
status: undefined,
}),
page: 0, // resets page index
},
})
);
};
const handleMacSettingsStatusDropdownChange = (
newMacSettingsStatus: MacSettingsStatusQueryParam
) => {
router.replace(
getNextLocationPath({
pathPrefix: PATHS.MANAGE_HOSTS,
routeTemplate,
routeParams,
queryParams: {
...queryParams,
apple_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 toast, 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) {
notify.error("Couldn't save column settings. Please try again.", {
response,
});
}
};
// 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.apple_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.macos_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,
})
);
notify.success(
`Successfully ${selectedSecret ? "edited" : "added"} enroll secret.`
);
} catch (error) {
console.error(error);
notify.error(
`Could not ${
selectedSecret ? "edit" : "add"
} enroll secret. Please try again.`,
{ response: error }
);
} 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,
})
);
notify.success(`Successfully deleted enroll secret.`);
} catch (error) {
console.error(error);
notify.error("Could not delete enroll secret. Please try again.", {
response: error,
});
} finally {
setIsUpdating(false);
}
};
const onDeleteLabel = async () => {
if (!selectedLabel) {
console.error("Label isn't available. This should not happen.");
return;
}
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,
})
);
notify.success("Successfully deleted label.");
} catch (error) {
notify.error(getDeleteLabelErrorMessages(error), { response: 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}.`;
notify.success(successMessage);
setResetSelectedRows(true);
refetchHosts();
toggleTransferHostModal();
setSelectedHostIds([]);
setIsAllMatchingHostsSelected(false);
} catch (error) {
notify.error("Could not transfer hosts. Please try again.", {
response: error,
});
} 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 = "Hosts successfully deleted.";
notify.success(successMessage);
setResetSelectedRows(true);
refetchHosts();
refetchLabels();
toggleDeleteHostModal();
setSelectedHostIds([]);
setIsAllMatchingHostsSelected(false);
} catch (error) {
notify.error("Could not delete hosts. Please try again.", {
response: error,
});
} 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 (
<FleetsDropdown
currentUserFleets={userTeams}
selectedFleetId={currentTeamId}
onChange={onTeamChange}
includeUnassigned
/>
);
}
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 = useCallback(
async (evt: React.MouseEvent<HTMLButtonElement>) => {
evt.preventDefault();
let visibleColumns;
if (config && currentUser) {
const tableColumns = generateVisibleTableColumns({
hiddenColumns,
isFreeTier,
isOnlyObserver,
teamId: teamIdForApi,
});
const columnIds = tableColumns
.map((column) => (column.id ? column.id : ""))
// "selection" column does not include any relevant data for the CSV
// so we filter it out.
.filter((element) => element !== "" && element !== "selection")
// "agent" is a display-only column that coalesces orbit and osquery
// versions; it has no corresponding CSV field on the backend, so we
// substitute the real fields it's derived from.
.reduce((acc: string[], element) => {
if (element === "agent") {
acc.push("orbit_version", "osquery_version");
} else {
acc.push(element);
}
return acc;
}, []);
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,
diskEncryptionStatus,
depProfileError: strToBool(depProfileError),
depAssignProfileResponse,
};
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);
notify.error("Could not export hosts. Please try again.", {
response: error,
});
}
},
[
config,
currentUser,
isFreeTier,
isOnlyObserver,
teamIdForApi,
selectedLabels,
searchQuery,
sortBy,
policyId,
policyResponse,
macSettingsStatus,
softwareId,
softwareTitleId,
softwareVersionId,
softwareStatus,
status,
mdmId,
mdmEnrollmentStatus,
munkiIssueId,
lowDiskSpaceHosts,
osName,
osVersionId,
osVersion,
osSettingsStatus,
bootstrapPackageStatus,
vulnerability,
configProfileUUID,
configProfileStatus,
scriptBatchExecutionStatus,
scriptBatchExecutionId,
diskEncryptionStatus,
depProfileError,
depAssignProfileResponse,
hiddenColumns,
queryParams.fleet_id,
]
);
// 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
);
// No hosts enrolled at all, no filters active
const isTrulyEmpty = maybeEmptyHosts && !includesFilterQueryParam;
const renderHostCount = useCallback(() => {
return <TableCount name="hosts" count={totalFilteredHostsCount} />;
}, [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}__table-actions`}>
{(!!totalFilteredHostsCount || isTrulyEmpty) && (
<Button
className={`${baseClass}__export-btn`}
onClick={onExportHostsResults}
variant="secondary"
disabled={isTrulyEmpty}
>
<>
<Icon name="download" size="small" />
Export hosts
</>
</Button>
)}
<Button
className={`${baseClass}__edit-columns-btn`}
onClick={toggleEditColumnsModal}
variant="secondary"
disabled={isTrulyEmpty}
>
<>
<Icon name="columns" color="ui-fleet-black-75" />
Edit columns
</>
</Button>
</div>
<div className={`${baseClass}__filter-dropdowns`}>
<DropdownWrapper
name="status-filter"
value={status || mdmEnrollmentStatus || ""}
className={`${baseClass}__status-filter`}
options={hostSelectStatuses(isPremiumTier || false)}
onChange={handleStatusDropdownChange}
variant="table-filter"
isDisabled={isTrulyEmpty}
/>
<LabelFilterSelect
className={`${baseClass}__label-filter-dropdown`}
labels={labels ?? []}
canAddNewLabels={canAddNewLabels}
selectedLabel={selectedDropdownLabel ?? null}
onChange={handleLabelChange}
onAddLabel={onAddLabelClick}
isLoading={isLoadingLabels}
isDisabled={isTrulyEmpty}
/>
</div>
</>
);
};
// 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" />;
}
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 ${MAX_SCRIPT_BATCH_TARGETS.toLocaleString()} hosts to run a script.`;
}
}
const secondarySelectActions: IActionButtonProps[] = [
{
name: "run-script",
onClick: onClickRunScriptBatchAction,
buttonText: "Run script",
variant: "secondary",
iconSvg: "run",
hideButton: !canRunScriptBatch,
isDisabled: !!disableRunScriptBatchTooltipContent,
tooltipContent: disableRunScriptBatchTooltipContent,
},
{
name: "transfer",
onClick: onTransferToTeamClick,
buttonText: "Transfer",
variant: "secondary",
iconSvg: "transfer",
hideButton:
!isPremiumTier ||
(!isGlobalAdmin && !isGlobalMaintainer && !isGlobalTechnician) ||
isPrimoMode,
},
];
// Global technicians can transfer hosts between fleets on Fleet Premium,
// so they need the selection checkbox column for bulk transfer.
const canTransferHostsInBulk =
isGlobalTechnician && isPremiumTier && !isPrimoMode;
const tableColumns = generateVisibleTableColumns({
hiddenColumns,
isFreeTier,
isOnlyObserver:
!canTransferHostsInBulk &&
(isOnlyObserver ||
isGlobalTechnician ||
(!isOnGlobalTeam && !isTeamMaintainerOrTeamAdmin)),
teamId: teamIdForApi,
});
const emptyState = () => {
const emptyHosts: IEmptyStateProps = {
header: "No hosts match your filters",
info:
"Recently enrolled hosts will appear here after their first check-in.",
primaryButton: canEnrollHosts ? (
<Button onClick={toggleAddHostsModal} type="button">
Add hosts
</Button>
) : undefined,
};
if (isTrulyEmpty) {
emptyHosts.header = "No hosts";
if (canEnrollHosts) {
emptyHosts.info = (
<>
Fleet refers to computers, servers, and mobile devices as hosts.
<br />
Add a host to start seeing data.
</>
);
emptyHosts.primaryButton = (
<Button onClick={toggleAddHostsModal} type="button">
Add hosts
</Button>
);
} else {
emptyHosts.info =
"Fleet refers to computers, servers, and mobile devices as hosts.";
emptyHosts.primaryButton = undefined;
}
} else if (isLastPage) {
emptyHosts.header = "No more hosts to display";
emptyHosts.info =
"Expecting to see more hosts? Try again soon as the system catches up.";
emptyHosts.primaryButton = undefined;
}
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}
primarySelectAction={
// Global technicians cannot delete hosts, so hide the bulk Delete
// action while still allowing them to select hosts for transfer.
canTransferHostsInBulk
? undefined
: {
name: "delete host",
buttonText: "Delete",
iconSvg: "trash",
variant: "secondary",
onClick: onDeleteHostsClick,
}
}
secondarySelectActions={secondarySelectActions}
showMarkAllPages={!unsupportedFilter} // Shortterm fix for #17257
isAllPagesSelected={isAllMatchingHostsSelected}
totalCount={totalFilteredHostsCount}
searchable
disableSearch={isTrulyEmpty}
renderCount={renderHostCount}
searchToolTipText={HOSTS_SEARCH_BOX_TOOLTIP}
emptyComponent={() => (
<EmptyState
header={emptyState().header}
info={emptyState().info}
primaryButton={emptyState().primaryButton}
/>
)}
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.{" "}
<Button
variant="link"
onClick={() => setShowEnrollSecretModal(true)}
>
Manage enroll secrets
</Button>{" "}
to enroll hosts to{" "}
<b>{isAnyTeamSelected ? currentTeamName : "Fleet"}</b>.
</span>
</div>
</InfoBanner>
)
);
};
const showAddHostsButton = canEnrollHosts && !hasErrors;
return (
<>
<MainContent className={baseClass}>
<div className={`${baseClass}__header-wrap`}>
{renderHeader()}
<div className={`${baseClass}__button-wrap`}>
{canManageCustomHostVitals && !hasErrors && (
<Button
onClick={() =>
router.push(
getPathWithQueryParams(
PATHS.CONTROLS_VARIABLES_CUSTOM_HOST_VITALS,
{ fleet_id: teamIdForApi }
)
)
}
className={`${baseClass}__custom-host-vitals`}
variant="secondary"
>
<Icon name="pencil" />
<span>Custom host vitals</span>
</Button>
)}
{canEnrollHosts && !hasErrors && (
<Button
onClick={() => setShowEnrollSecretModal(true)}
className={`${baseClass}__enroll-hosts button`}
variant="secondary"
>
<Icon name="settings" />
<span>Enroll secrets</span>
</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;