mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
1701 lines
50 KiB
TypeScript
1701 lines
50 KiB
TypeScript
import React, {
|
||
useState,
|
||
useContext,
|
||
useEffect,
|
||
useCallback,
|
||
useMemo,
|
||
} from "react";
|
||
import { useQuery } from "react-query";
|
||
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 classNames from "classnames";
|
||
|
||
import enrollSecretsAPI from "services/entities/enroll_secret";
|
||
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,
|
||
} from "services/entities/hosts";
|
||
import hostCountAPI, {
|
||
IHostsCountQueryKey,
|
||
IHostsCountResponse,
|
||
} from "services/entities/host_count";
|
||
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 { ITeam } from "interfaces/team";
|
||
import { IEmptyTableProps } from "interfaces/empty_table";
|
||
import {
|
||
DiskEncryptionStatus,
|
||
BootstrapPackageStatus,
|
||
MdmProfileStatus,
|
||
} from "interfaces/mdm";
|
||
|
||
import sortUtils from "utilities/sort";
|
||
import {
|
||
HOSTS_SEARCH_BOX_PLACEHOLDER,
|
||
HOSTS_SEARCH_BOX_TOOLTIP,
|
||
PolicyResponse,
|
||
} from "utilities/constants";
|
||
import { getNextLocationPath } from "utilities/helpers";
|
||
|
||
import Button from "components/buttons/Button";
|
||
import Icon from "components/Icon/Icon";
|
||
// @ts-ignore
|
||
import Dropdown from "components/forms/fields/Dropdown";
|
||
import TableContainer from "components/TableContainer";
|
||
import InfoBanner from "components/InfoBanner/InfoBanner";
|
||
import { ITableQueryData } from "components/TableContainer/TableContainer";
|
||
import TableDataError 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,
|
||
getHostSelectStatuses,
|
||
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";
|
||
|
||
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
|
||
}
|
||
|
||
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,
|
||
isOnGlobalTeam,
|
||
isOnlyObserver,
|
||
isPremiumTier,
|
||
isFreeTier,
|
||
isSandboxMode,
|
||
setFilteredHostsPath,
|
||
} = useContext(AppContext);
|
||
const { renderFlash } = useContext(NotificationContext);
|
||
|
||
const { setResetSelectedRows } = useContext(TableContext);
|
||
|
||
const {
|
||
currentTeamId,
|
||
currentTeamName,
|
||
isAnyTeamSelected,
|
||
isRouteOk,
|
||
isTeamAdmin,
|
||
isTeamMaintainer,
|
||
isTeamMaintainerOrTeamAdmin,
|
||
teamIdForApi,
|
||
userTeams,
|
||
handleTeamChange,
|
||
} = useTeamIdParam({
|
||
location,
|
||
router,
|
||
includeAllTeams: true,
|
||
includeNoTeam: true,
|
||
});
|
||
|
||
const hostHiddenColumns = localStorage.getItem("hostHiddenColumns");
|
||
const storedHiddenColumns = hostHiddenColumns
|
||
? JSON.parse(hostHiddenColumns)
|
||
: null;
|
||
|
||
// 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 initialPage = (() =>
|
||
queryParams && queryParams.page ? parseInt(queryParams?.page, 10) : 0)();
|
||
|
||
// ========= states
|
||
const [selectedLabel, setSelectedLabel] = useState<ILabel>();
|
||
const [selectedSecret, setSelectedSecret] = useState<IEnrollSecret>();
|
||
const [showNoEnrollSecretBanner, setShowNoEnrollSecretBanner] = useState(
|
||
true
|
||
);
|
||
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 [hiddenColumns, setHiddenColumns] = useState<string[]>(
|
||
storedHiddenColumns || defaultHiddenColumns
|
||
);
|
||
const [selectedHostIds, setSelectedHostIds] = useState<number[]>([]);
|
||
const [isAllMatchingHostsSelected, setIsAllMatchingHostsSelected] = useState(
|
||
false
|
||
);
|
||
const [searchQuery, setSearchQuery] = useState(initialQuery);
|
||
const [page, setPage] = useState(initialPage);
|
||
const [sortBy, setSortBy] = useState<ISortOption[]>(initialSortBy);
|
||
const [tableQueryData, setTableQueryData] = useState<ITableQueryData>();
|
||
const [resetPageIndex, setResetPageIndex] = useState<boolean>(false);
|
||
const [isUpdatingLabel, setIsUpdatingLabel] = useState<boolean>(false);
|
||
const [isUpdatingSecret, setIsUpdatingSecret] = useState<boolean>(false);
|
||
const [isUpdatingHosts, setIsUpdatingHosts] = 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 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;
|
||
|
||
// ========= routeParams
|
||
const { active_label: activeLabel, label_id: labelID } = routeParams;
|
||
const selectedFilters = useMemo(() => {
|
||
const filters: string[] = [];
|
||
labelID && filters.push(`${LABEL_SLUG_PREFIX}${labelID}`);
|
||
activeLabel && filters.push(activeLabel);
|
||
return filters;
|
||
}, [activeLabel, labelID]);
|
||
|
||
// ========= derived permissions
|
||
const canEnrollHosts =
|
||
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
|
||
const canEnrollGlobalHosts = isGlobalAdmin || isGlobalMaintainer;
|
||
const canAddNewLabels = (isGlobalAdmin || isGlobalMaintainer) ?? false;
|
||
|
||
const { data: labels, refetch: refetchLabels } = useQuery<
|
||
ILabelsResponse,
|
||
Error,
|
||
ILabel[]
|
||
>(["labels"], () => labelsAPI.loadAll(), {
|
||
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: osVersions } = 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: selectedFilters,
|
||
globalFilter: searchQuery,
|
||
sortBy,
|
||
teamId: teamIdForApi,
|
||
policyId,
|
||
policyResponse,
|
||
softwareId,
|
||
softwareTitleId,
|
||
softwareVersionId,
|
||
status,
|
||
mdmId,
|
||
mdmEnrollmentStatus,
|
||
munkiIssueId,
|
||
lowDiskSpaceHosts,
|
||
osVersionId,
|
||
osName,
|
||
osVersion,
|
||
vulnerability,
|
||
page: tableQueryData ? tableQueryData.pageIndex : 0,
|
||
perPage: tableQueryData ? tableQueryData.pageSize : 50,
|
||
device_mapping: true,
|
||
osSettings: osSettingsStatus,
|
||
diskEncryptionStatus,
|
||
bootstrapPackageStatus,
|
||
macSettingsStatus,
|
||
},
|
||
],
|
||
({ queryKey }) => hostsAPI.loadHosts(queryKey[0]),
|
||
{
|
||
enabled: isRouteOk,
|
||
keepPreviousData: true,
|
||
staleTime: 10000, // stale time can be adjusted if fresher data is desired
|
||
}
|
||
);
|
||
|
||
const {
|
||
data: hostsCount,
|
||
error: errorHostsCount,
|
||
isFetching: isLoadingHostsCount,
|
||
refetch: refetchHostsCountAPI,
|
||
} = useQuery<IHostsCountResponse, Error, number, IHostsCountQueryKey[]>(
|
||
[
|
||
{
|
||
scope: "hosts_count",
|
||
selectedLabels: selectedFilters,
|
||
globalFilter: searchQuery,
|
||
teamId: teamIdForApi,
|
||
policyId,
|
||
policyResponse,
|
||
softwareId,
|
||
softwareTitleId,
|
||
softwareVersionId,
|
||
status,
|
||
mdmId,
|
||
mdmEnrollmentStatus,
|
||
munkiIssueId,
|
||
lowDiskSpaceHosts,
|
||
osVersionId,
|
||
osName,
|
||
osVersion,
|
||
vulnerability,
|
||
osSettings: osSettingsStatus,
|
||
diskEncryptionStatus,
|
||
bootstrapPackageStatus,
|
||
macSettingsStatus,
|
||
},
|
||
],
|
||
({ 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,
|
||
}
|
||
);
|
||
|
||
const refetchHosts = () => {
|
||
refetchHostsAPI();
|
||
refetchHostsCountAPI();
|
||
};
|
||
|
||
const hasErrors = !!errorHosts || !!errorHostsCount || !!errorPolicy;
|
||
|
||
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 toggleEditColumnsModal = () => {
|
||
setShowEditColumnsModal(!showEditColumnsModal);
|
||
};
|
||
|
||
const toggleAllMatchingHosts = (shouldSelect: boolean) => {
|
||
if (typeof shouldSelect !== "undefined") {
|
||
setIsAllMatchingHostsSelected(shouldSelect);
|
||
} else {
|
||
setIsAllMatchingHostsSelected(!isAllMatchingHostsSelected);
|
||
}
|
||
};
|
||
|
||
// TODO: cleanup this effect
|
||
useEffect(() => {
|
||
setShowNoEnrollSecretBanner(true);
|
||
}, [teamIdForApi]);
|
||
|
||
// TODO: cleanup this effect
|
||
useEffect(() => {
|
||
const slugToFind =
|
||
(selectedFilters.length > 0 &&
|
||
selectedFilters.find((f) => f.includes(LABEL_SLUG_PREFIX))) ||
|
||
selectedFilters[0];
|
||
const validLabel = find(labels, ["slug", slugToFind]) as ILabel;
|
||
if (selectedLabel !== validLabel) {
|
||
setSelectedLabel(validLabel);
|
||
}
|
||
}, [labels, selectedFilters, selectedLabel]);
|
||
|
||
// TODO: cleanup this effect
|
||
useEffect(() => {
|
||
if (
|
||
location.search.match(
|
||
/software_id|software_version_id|software_title_id/gi
|
||
)
|
||
) {
|
||
// regex matches any of "software_id", "software_version_id", or "software_title_id"
|
||
// 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 &&
|
||
!!hostsCount &&
|
||
DEFAULT_PAGE_SIZE * tableQueryData.pageIndex +
|
||
(hostsData?.hosts?.length || 0) >=
|
||
hostsCount;
|
||
|
||
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;
|
||
};
|
||
|
||
// NOTE: used to reset page number to 0 when modifying filters
|
||
const handleResetPageIndex = () => {
|
||
setTableQueryData(
|
||
(prevState) =>
|
||
({
|
||
...prevState,
|
||
pageIndex: 0,
|
||
} as ITableQueryData)
|
||
);
|
||
setResetPageIndex(true);
|
||
};
|
||
|
||
const handleChangePoliciesFilter = (response: PolicyResponse) => {
|
||
handleResetPageIndex();
|
||
|
||
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
|
||
) => {
|
||
handleResetPageIndex();
|
||
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams,
|
||
queryParams: {
|
||
...queryParams,
|
||
[PARAMS.DISK_ENCRYPTION]: newStatus,
|
||
page: 0, // resets page index
|
||
},
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleChangeOsSettingsFilter = (newStatus: MdmProfileStatus) => {
|
||
handleResetPageIndex();
|
||
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams,
|
||
queryParams: {
|
||
...queryParams,
|
||
[PARAMS.OS_SETTINGS]: newStatus,
|
||
page: 0, // resets page index
|
||
},
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleChangeBootstrapPackageStatusFilter = (
|
||
newStatus: BootstrapPackageStatus
|
||
) => {
|
||
handleResetPageIndex();
|
||
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams,
|
||
queryParams: { ...queryParams, bootstrap_package: newStatus },
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleClearRouteParam = () => {
|
||
handleResetPageIndex();
|
||
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams: undefined,
|
||
queryParams: {
|
||
...queryParams,
|
||
page: 0, // resets page index
|
||
},
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleClearFilter = (omitParams: string[]) => {
|
||
handleResetPageIndex();
|
||
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams,
|
||
queryParams: {
|
||
...omit(queryParams, omitParams),
|
||
page: 0, // resets page index
|
||
},
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleStatusDropdownChange = (statusName: string) => {
|
||
handleResetPageIndex();
|
||
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams,
|
||
queryParams: {
|
||
...queryParams,
|
||
status: statusName,
|
||
page: 0, // resets page index
|
||
},
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleMacSettingsStatusDropdownChange = (
|
||
newMacSettingsStatus: MacSettingsStatusQueryParam
|
||
) => {
|
||
handleResetPageIndex();
|
||
|
||
router.replace(
|
||
getNextLocationPath({
|
||
pathPrefix: PATHS.MANAGE_HOSTS,
|
||
routeTemplate,
|
||
routeParams,
|
||
queryParams: {
|
||
...queryParams,
|
||
macos_settings: newMacSettingsStatus,
|
||
page: 0, // resets page index
|
||
},
|
||
})
|
||
);
|
||
};
|
||
|
||
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 = (newHiddenColumns: string[]) => {
|
||
localStorage.setItem("hostHiddenColumns", JSON.stringify(newHiddenColumns));
|
||
setHiddenColumns(newHiddenColumns);
|
||
setShowEditColumnsModal(false);
|
||
};
|
||
|
||
// NOTE: used to reset page number to 0 when modifying filters
|
||
useEffect(() => {
|
||
// TODO: cleanup this effect
|
||
setResetPageIndex(false);
|
||
if (queryParams.add_hosts === "true") {
|
||
setShowAddHostsModal(true);
|
||
}
|
||
if (queryParams.page === page) {
|
||
setPage(queryParams.page);
|
||
}
|
||
}, [queryParams, page]);
|
||
|
||
// 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);
|
||
}
|
||
|
||
if (!isEqual(page, pageIndex)) {
|
||
setPage(pageIndex);
|
||
}
|
||
|
||
// 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.team_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;
|
||
} else if (softwareTitleId) {
|
||
newQueryParams.software_title_id = softwareTitleId;
|
||
} 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;
|
||
}
|
||
|
||
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,
|
||
page,
|
||
router,
|
||
routeTemplate,
|
||
routeParams,
|
||
osSettingsStatus,
|
||
diskEncryptionStatus,
|
||
bootstrapPackageStatus,
|
||
]
|
||
);
|
||
|
||
const onTeamChange = useCallback(
|
||
(teamId: number) => {
|
||
// TODO(sarah): refactor so that this doesn't trigger two api calls (reset page index updates
|
||
// tableQueryData)
|
||
handleTeamChange(teamId);
|
||
handleResetPageIndex();
|
||
},
|
||
[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 });
|
||
}
|
||
|
||
setIsUpdatingSecret(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 {
|
||
setIsUpdatingSecret(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
|
||
);
|
||
|
||
setIsUpdatingSecret(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 {
|
||
setIsUpdatingSecret(false);
|
||
}
|
||
};
|
||
|
||
const onDeleteLabel = async () => {
|
||
if (!selectedLabel) {
|
||
console.error("Label isn't available. This should not happen.");
|
||
return false;
|
||
}
|
||
setIsUpdatingLabel(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) {
|
||
console.error(error);
|
||
renderFlash("error", "Could not delete label. Please try again.");
|
||
} finally {
|
||
setIsUpdatingLabel(false);
|
||
}
|
||
};
|
||
|
||
const onTransferToTeamClick = (hostIds: number[]) => {
|
||
toggleTransferHostModal();
|
||
setSelectedHostIds(hostIds);
|
||
};
|
||
|
||
const onDeleteHostsClick = (hostIds: number[]) => {
|
||
toggleDeleteHostModal();
|
||
setSelectedHostIds(hostIds);
|
||
};
|
||
|
||
// Bulk transfer is hidden for defined unsupportedFilters
|
||
const onTransferHostSubmit = async (transferTeam: ITeam) => {
|
||
setIsUpdatingHosts(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,
|
||
osName,
|
||
osVersionId,
|
||
osVersion,
|
||
macSettingsStatus,
|
||
bootstrapPackageStatus,
|
||
mdmId,
|
||
mdmEnrollmentStatus,
|
||
munkiIssueId,
|
||
lowDiskSpaceHosts,
|
||
osSettings: osSettingsStatus,
|
||
diskEncryptionStatus,
|
||
vulnerability,
|
||
})
|
||
: hostsAPI.transferToTeam(teamId, selectedHostIds);
|
||
|
||
try {
|
||
await action;
|
||
|
||
const successMessage =
|
||
teamId === null
|
||
? `Hosts successfully removed from teams.`
|
||
: `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 {
|
||
setIsUpdatingHosts(false);
|
||
}
|
||
};
|
||
|
||
// Bulk delete is hidden for defined unsupportedFilters
|
||
const onDeleteHostSubmit = async () => {
|
||
setIsUpdatingHosts(true);
|
||
|
||
const teamId = isAnyTeamSelected ? currentTeamId ?? null : null;
|
||
|
||
try {
|
||
await (isAllMatchingHostsSelected
|
||
? hostsAPI.destroyByFilter({
|
||
teamId,
|
||
query: searchQuery,
|
||
status,
|
||
labelId: selectedLabel?.id,
|
||
policyId,
|
||
policyResponse,
|
||
softwareId,
|
||
softwareTitleId,
|
||
softwareVersionId,
|
||
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 {
|
||
setIsUpdatingHosts(false);
|
||
}
|
||
};
|
||
|
||
const renderTeamsFilterDropdown = () => (
|
||
<TeamsDropdown
|
||
currentUserTeams={userTeams || []}
|
||
selectedTeamId={currentTeamId}
|
||
isDisabled={isLoadingHosts || isLoadingHostsCount} // TODO: why?
|
||
onChange={onTeamChange}
|
||
includeNoTeams
|
||
isSandboxMode={isSandboxMode}
|
||
/>
|
||
);
|
||
|
||
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}
|
||
teams={teams || []}
|
||
onSaveSecret={onSaveSecret}
|
||
toggleSecretEditorModal={toggleSecretEditorModal}
|
||
selectedSecret={selectedSecret}
|
||
isUpdatingSecret={isUpdatingSecret}
|
||
/>
|
||
);
|
||
|
||
const renderDeleteSecretModal = () => (
|
||
<DeleteSecretModal
|
||
onDeleteSecret={onDeleteSecret}
|
||
selectedTeam={teamIdForApi || 0}
|
||
teams={teams || []}
|
||
toggleDeleteSecretModal={toggleDeleteSecretModal}
|
||
isUpdatingSecret={isUpdatingSecret}
|
||
/>
|
||
);
|
||
|
||
const renderEnrollSecretModal = () => (
|
||
<EnrollSecretModal
|
||
selectedTeam={teamIdForApi || 0}
|
||
teams={teams || []}
|
||
onReturnToApp={() => setShowEnrollSecretModal(false)}
|
||
toggleSecretEditorModal={toggleSecretEditorModal}
|
||
toggleDeleteSecretModal={toggleDeleteSecretModal}
|
||
setSelectedSecret={setSelectedSecret}
|
||
globalSecrets={globalSecrets}
|
||
/>
|
||
);
|
||
|
||
const renderDeleteLabelModal = () => (
|
||
<DeleteLabelModal
|
||
onSubmit={onDeleteLabel}
|
||
onCancel={toggleDeleteLabelModal}
|
||
isUpdatingLabel={isUpdatingLabel}
|
||
/>
|
||
);
|
||
|
||
const renderAddHostsModal = () => {
|
||
const enrollSecret =
|
||
// TODO: Currently, prepacked installers in Fleet Sandbox use the global enroll secret,
|
||
// and Fleet Sandbox runs Fleet Free so the isSandboxMode check here is an
|
||
// additional precaution/reminder to revisit this in connection with future changes.
|
||
// See https://github.com/fleetdm/fleet/issues/4970#issuecomment-1187679407.
|
||
isAnyTeamSelected && !isSandboxMode
|
||
? teamSecrets?.[0].secret
|
||
: globalSecrets?.[0].secret;
|
||
return (
|
||
<AddHostsModal
|
||
currentTeamName={currentTeamName || "Fleet"}
|
||
enrollSecret={enrollSecret}
|
||
isAnyTeamSelected={isAnyTeamSelected}
|
||
isLoading={isLoadingTeams || isGlobalSecretsLoading}
|
||
isSandboxMode={!!isSandboxMode}
|
||
onCancel={toggleAddHostsModal}
|
||
openEnrollSecretModal={() => setShowEnrollSecretModal(true)}
|
||
/>
|
||
);
|
||
};
|
||
|
||
const renderTransferHostModal = () => {
|
||
if (!teams) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<TransferHostModal
|
||
isGlobalAdmin={isGlobalAdmin as boolean}
|
||
teams={teams}
|
||
onSubmit={onTransferHostSubmit}
|
||
onCancel={toggleTransferHostModal}
|
||
isUpdating={isUpdatingHosts}
|
||
multipleHosts={selectedHostIds.length > 1}
|
||
/>
|
||
);
|
||
};
|
||
|
||
const renderDeleteHostModal = () => (
|
||
<DeleteHostModal
|
||
selectedHostIds={selectedHostIds}
|
||
onSubmit={onDeleteHostSubmit}
|
||
onCancel={toggleDeleteHostModal}
|
||
isAllMatchingHostsSelected={isAllMatchingHostsSelected}
|
||
hostsCount={hostsCount}
|
||
isUpdating={isUpdatingHosts}
|
||
/>
|
||
);
|
||
|
||
const renderHeader = () => (
|
||
<div className={`${baseClass}__header`}>
|
||
<div className={`${baseClass}__text`}>
|
||
<div className={`${baseClass}__title`}>
|
||
{isFreeTier && <h1>Hosts</h1>}
|
||
{isPremiumTier &&
|
||
userTeams &&
|
||
(userTeams.length > 1 || isOnGlobalTeam) &&
|
||
renderTeamsFilterDropdown()}
|
||
{isPremiumTier &&
|
||
!isOnGlobalTeam &&
|
||
userTeams &&
|
||
userTeams.length === 1 && <h1>{userTeams[0].name}</h1>}
|
||
</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,
|
||
});
|
||
|
||
const columnAccessors = tableColumns
|
||
.map((column) => (column.accessor ? column.accessor : ""))
|
||
.filter((element) => element);
|
||
visibleColumns = columnAccessors.join(",");
|
||
}
|
||
|
||
let options = {
|
||
selectedLabels: selectedFilters,
|
||
globalFilter: searchQuery,
|
||
sortBy,
|
||
teamId: teamIdForApi,
|
||
policyId,
|
||
policyResponse,
|
||
macSettingsStatus,
|
||
softwareId,
|
||
softwareTitleId,
|
||
softwareVersionId,
|
||
status,
|
||
mdmId,
|
||
mdmEnrollmentStatus,
|
||
munkiIssueId,
|
||
lowDiskSpaceHosts,
|
||
os_version_id: osVersionId,
|
||
os_name: osName,
|
||
os_version: osVersion,
|
||
vulnerability,
|
||
visibleColumns,
|
||
};
|
||
|
||
options = {
|
||
...options,
|
||
teamId: teamIdForApi,
|
||
};
|
||
|
||
if (queryParams.team_id) {
|
||
options.teamId = queryParams.team_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(() => {
|
||
const count = hostsCount;
|
||
|
||
return (
|
||
<div
|
||
className={`${baseClass}__count ${
|
||
isLoadingHostsCount ? "count-loading" : ""
|
||
}`}
|
||
>
|
||
{count !== undefined && (
|
||
<span>{`${count} host${count === 1 ? "" : "s"}`}</span>
|
||
)}
|
||
{!!count && (
|
||
<Button
|
||
className={`${baseClass}__export-btn`}
|
||
onClick={onExportHostsResults}
|
||
variant="text-icon"
|
||
>
|
||
<>
|
||
Export hosts
|
||
<Icon name="download" size="small" color="core-fleet-blue" />
|
||
</>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
);
|
||
}, [isLoadingHostsCount, hostsCount]);
|
||
|
||
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;
|
||
|
||
const statusDropdownClassnames = classNames(
|
||
`${baseClass}__status_dropdown`,
|
||
{ [`${baseClass}__status-dropdown-sandbox`]: isSandboxMode }
|
||
);
|
||
|
||
return (
|
||
<div className={`${baseClass}__filter-dropdowns`}>
|
||
<Dropdown
|
||
value={status || ""}
|
||
className={statusDropdownClassnames}
|
||
options={getHostSelectStatuses(isSandboxMode)}
|
||
searchable={false}
|
||
onChange={handleStatusDropdownChange}
|
||
tableFilterDropdown
|
||
/>
|
||
<LabelFilterSelect
|
||
className={`${baseClass}__label-filter-dropdown`}
|
||
labels={labels ?? []}
|
||
canAddNewLabels={canAddNewLabels}
|
||
selectedLabel={selectedDropdownLabel ?? null}
|
||
onChange={handleLabelChange}
|
||
onAddLabel={onAddLabelClick}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// TODO: try to reduce overlap between maybeEmptyHosts and includesFilterQueryParam
|
||
const maybeEmptyHosts =
|
||
hostsCount === 0 && searchQuery === "" && !labelID && !status;
|
||
|
||
const includesFilterQueryParam = MANAGE_HOSTS_PAGE_FILTER_KEYS.some(
|
||
(filter) =>
|
||
filter !== "team_id" &&
|
||
typeof queryParams === "object" &&
|
||
filter in queryParams // TODO: replace this with `Object.hasOwn(queryParams, filter)` when we upgrade to es2022
|
||
);
|
||
|
||
const renderTable = () => {
|
||
if (!config || !currentUser || !isRouteOk) {
|
||
return <Spinner />;
|
||
}
|
||
|
||
if (hasErrors) {
|
||
return <TableDataError />;
|
||
}
|
||
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 in a few seconds 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 in a few seconds as the system catches up.";
|
||
} else if (canEnrollHosts) {
|
||
emptyHosts.header = "Add your hosts to Fleet";
|
||
emptyHosts.info = "Generate an installer to add your own hosts.";
|
||
emptyHosts.primaryButton = (
|
||
<Button variant="brand" 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,
|
||
})}
|
||
</>
|
||
);
|
||
}
|
||
|
||
const secondarySelectActions: IActionButtonProps[] = [
|
||
{
|
||
name: "transfer",
|
||
onActionButtonClick: onTransferToTeamClick,
|
||
buttonText: "Transfer",
|
||
variant: "text-icon",
|
||
iconSvg: "transfer",
|
||
hideButton: !isPremiumTier || (!isGlobalAdmin && !isGlobalMaintainer),
|
||
indicatePremiumFeature: isPremiumTier && isSandboxMode,
|
||
},
|
||
];
|
||
|
||
const tableColumns = generateVisibleTableColumns({
|
||
hiddenColumns,
|
||
isFreeTier,
|
||
isOnlyObserver:
|
||
isOnlyObserver || (!isOnGlobalTeam && !isTeamMaintainerOrTeamAdmin),
|
||
});
|
||
|
||
const emptyState = () => {
|
||
const emptyHosts: IEmptyTableProps = {
|
||
header: "No hosts match the current criteria",
|
||
info:
|
||
"Expecting to see new hosts? Try again in a few seconds as the system catches up.",
|
||
};
|
||
if (isLastPage) {
|
||
emptyHosts.header = "No more hosts to display";
|
||
emptyHosts.info =
|
||
"Expecting to see more hosts? Try again in a few seconds 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
|
||
);
|
||
|
||
return (
|
||
<TableContainer
|
||
resultsTitle="hosts"
|
||
columnConfigs={tableColumns}
|
||
data={hostsData?.hosts || []}
|
||
isLoading={isLoadingHosts || isLoadingHostsCount || isLoadingPolicy}
|
||
manualSortBy
|
||
defaultSortHeader={(sortBy[0] && sortBy[0].key) || DEFAULT_SORT_HEADER}
|
||
defaultSortDirection={
|
||
(sortBy[0] && sortBy[0].direction) || DEFAULT_SORT_DIRECTION
|
||
}
|
||
defaultPageIndex={page || DEFAULT_PAGE_INDEX}
|
||
defaultSearchQuery={searchQuery}
|
||
pageSize={50}
|
||
additionalQueries={JSON.stringify(selectedFilters)}
|
||
inputPlaceHolder={HOSTS_SEARCH_BOX_PLACEHOLDER}
|
||
actionButton={{
|
||
name: "edit columns",
|
||
buttonText: "Edit columns",
|
||
iconSvg: "columns",
|
||
variant: "text-icon",
|
||
onActionButtonClick: toggleEditColumnsModal,
|
||
}}
|
||
primarySelectAction={{
|
||
name: "delete host",
|
||
buttonText: "Delete",
|
||
iconSvg: "trash",
|
||
variant: "text-icon",
|
||
onActionButtonClick: 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}
|
||
resetPageIndex={resetPageIndex}
|
||
disableNextPage={isLastPage}
|
||
/>
|
||
);
|
||
};
|
||
|
||
const renderNoEnrollSecretBanner = () => {
|
||
const noTeamEnrollSecrets =
|
||
isAnyTeamSelected && !isTeamSecretsLoading && !teamSecrets?.length;
|
||
const noGlobalEnrollSecrets =
|
||
(!isPremiumTier ||
|
||
(isPremiumTier && !isAnyTeamSelected && !isLoadingTeams)) &&
|
||
!isGlobalSecretsLoading &&
|
||
!globalSecrets?.length;
|
||
|
||
return (
|
||
((canEnrollHosts && noTeamEnrollSecrets) ||
|
||
(canEnrollGlobalHosts && noGlobalEnrollSecrets)) &&
|
||
showNoEnrollSecretBanner && (
|
||
<InfoBanner
|
||
className={`${baseClass}__no-enroll-secret-banner`}
|
||
pageLevel
|
||
closable
|
||
color="grey"
|
||
>
|
||
<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>
|
||
<div className={`${baseClass}`}>
|
||
<div className="header-wrap">
|
||
{renderHeader()}
|
||
<div className={`${baseClass} button-wrap`}>
|
||
{!isSandboxMode && canEnrollHosts && !hasErrors && (
|
||
<Button
|
||
onClick={() => setShowEnrollSecretModal(true)}
|
||
className={`${baseClass}__enroll-hosts button`}
|
||
variant="inverse"
|
||
>
|
||
<span>Manage enroll secret</span>
|
||
</Button>
|
||
)}
|
||
{showAddHostsButton && (
|
||
<Button
|
||
onClick={toggleAddHostsModal}
|
||
className={`${baseClass}__add-hosts`}
|
||
variant="brand"
|
||
>
|
||
<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,
|
||
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,
|
||
}}
|
||
selectedLabel={selectedLabel}
|
||
isOnlyObserver={isOnlyObserver}
|
||
handleClearRouteParam={handleClearRouteParam}
|
||
handleClearFilter={handleClearFilter}
|
||
onChangePoliciesFilter={handleChangePoliciesFilter}
|
||
onChangeOsSettingsFilter={handleChangeOsSettingsFilter}
|
||
onChangeDiskEncryptionStatusFilter={
|
||
handleChangeDiskEncryptionStatusFilter
|
||
}
|
||
onChangeBootstrapPackageStatusFilter={
|
||
handleChangeBootstrapPackageStatusFilter
|
||
}
|
||
onChangeMacSettingsFilter={handleMacSettingsStatusDropdownChange}
|
||
onClickEditLabel={onEditLabelClick}
|
||
onClickDeleteLabel={toggleDeleteLabelModal}
|
||
isSandboxMode={isSandboxMode}
|
||
/>
|
||
{renderNoEnrollSecretBanner()}
|
||
{renderTable()}
|
||
</div>
|
||
</MainContent>
|
||
{canEnrollHosts && showDeleteSecretModal && renderDeleteSecretModal()}
|
||
{canEnrollHosts && showSecretEditorModal && renderSecretEditorModal()}
|
||
{canEnrollHosts && showEnrollSecretModal && renderEnrollSecretModal()}
|
||
{showEditColumnsModal && renderEditColumnsModal()}
|
||
{showDeleteLabelModal && renderDeleteLabelModal()}
|
||
{showAddHostsModal && renderAddHostsModal()}
|
||
{showTransferHostModal && renderTransferHostModal()}
|
||
{showDeleteHostModal && renderDeleteHostModal()}
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default ManageHostsPage;
|