mirror of
https://github.com/empayre/fleet.git
synced 2024-11-07 17:28:54 +00:00
1825 lines
51 KiB
TypeScript
1825 lines
51 KiB
TypeScript
import React, { useState, useContext, useEffect, useCallback } 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 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, {
|
|
ILoadHostsOptions,
|
|
ISortOption,
|
|
} from "services/entities/hosts";
|
|
import hostCountAPI, {
|
|
IHostCountLoadOptions,
|
|
} 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 {
|
|
IEnrollSecret,
|
|
IEnrollSecretsResponse,
|
|
} from "interfaces/enroll_secret";
|
|
import { IHost } from "interfaces/host";
|
|
import { ILabel } from "interfaces/label";
|
|
import { IMdmSolution, IMunkiIssuesAggregate } from "interfaces/macadmins";
|
|
import {
|
|
formatOperatingSystemDisplayName,
|
|
IOperatingSystemVersion,
|
|
} from "interfaces/operating_system";
|
|
import { IPolicy, IStoredPolicyResponse } from "interfaces/policy";
|
|
import { ISoftware } from "interfaces/software";
|
|
import { ITeam } from "interfaces/team";
|
|
import sortUtils from "utilities/sort";
|
|
import {
|
|
HOSTS_SEARCH_BOX_PLACEHOLDER,
|
|
HOSTS_SEARCH_BOX_TOOLTIP,
|
|
PLATFORM_LABEL_DISPLAY_NAMES,
|
|
PolicyResponse,
|
|
} from "utilities/constants";
|
|
|
|
import Button from "components/buttons/Button";
|
|
// @ts-ignore
|
|
import Dropdown from "components/forms/fields/Dropdown";
|
|
import TableContainer from "components/TableContainer";
|
|
import TableDataError from "components/DataError";
|
|
import { IActionButtonProps } from "components/TableContainer/DataTable/ActionButton";
|
|
import TeamsDropdown from "components/TeamsDropdown";
|
|
import Spinner from "components/Spinner";
|
|
import MainContent from "components/MainContent";
|
|
|
|
import { getValidatedTeamId } from "utilities/helpers";
|
|
import {
|
|
defaultHiddenColumns,
|
|
generateVisibleTableColumns,
|
|
generateAvailableTableHeaders,
|
|
} from "./HostTableConfig";
|
|
import {
|
|
LABEL_SLUG_PREFIX,
|
|
DEFAULT_SORT_HEADER,
|
|
DEFAULT_SORT_DIRECTION,
|
|
DEFAULT_PAGE_SIZE,
|
|
HOST_SELECT_STATUSES,
|
|
} from "./constants";
|
|
import { isAcceptableStatus, getNextLocationPath } 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";
|
|
import NoHosts from "./components/NoHosts";
|
|
import EmptyHosts from "./components/EmptyHosts";
|
|
import PoliciesFilter from "./components/PoliciesFilter";
|
|
// @ts-ignore
|
|
import EditColumnsModal from "./components/EditColumnsModal/EditColumnsModal";
|
|
import TransferHostModal from "./components/TransferHostModal";
|
|
import DeleteHostModal from "./components/DeleteHostModal";
|
|
import DeleteLabelModal from "./components/DeleteLabelModal";
|
|
import EditColumnsIcon from "../../../../assets/images/icon-edit-columns-16x16@2x.png";
|
|
import PencilIcon from "../../../../assets/images/icon-pencil-14x14@2x.png";
|
|
import TrashIcon from "../../../../assets/images/icon-trash-14x14@2x.png";
|
|
import CloseIconBlack from "../../../../assets/images/icon-close-fleet-black-16x16@2x.png";
|
|
import PolicyIcon from "../../../../assets/images/icon-policy-fleet-black-12x12@2x.png";
|
|
import DownloadIcon from "../../../../assets/images/icon-download-12x12@2x.png";
|
|
import LabelFilterSelect from "./components/LabelFilterSelect";
|
|
import FilterPill from "./components/FilterPill";
|
|
|
|
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
|
|
}
|
|
|
|
interface ITableQueryProps {
|
|
pageIndex: number;
|
|
pageSize: number;
|
|
searchQuery: string;
|
|
sortHeader: string;
|
|
sortDirection: string;
|
|
}
|
|
|
|
const CSV_HOSTS_TITLE = "Hosts";
|
|
const baseClass = "manage-hosts";
|
|
|
|
const ManageHostsPage = ({
|
|
route,
|
|
router,
|
|
params: routeParams,
|
|
location,
|
|
}: IManageHostsProps): JSX.Element => {
|
|
const queryParams = location.query;
|
|
|
|
const {
|
|
availableTeams,
|
|
config,
|
|
currentTeam,
|
|
currentUser,
|
|
isGlobalAdmin,
|
|
isGlobalMaintainer,
|
|
isTeamMaintainer,
|
|
isTeamAdmin,
|
|
isOnGlobalTeam,
|
|
isOnlyObserver,
|
|
isPremiumTier,
|
|
isFreeTier,
|
|
isSandboxMode,
|
|
setAvailableTeams,
|
|
setCurrentTeam,
|
|
} = useContext(AppContext);
|
|
const { renderFlash } = useContext(NotificationContext);
|
|
|
|
if (queryParams.team_id) {
|
|
const teamIdParam = parseInt(queryParams.team_id, 10);
|
|
if (
|
|
isNaN(teamIdParam) ||
|
|
(teamIdParam &&
|
|
availableTeams &&
|
|
!availableTeams.find(
|
|
(availableTeam) => availableTeam.id === teamIdParam
|
|
))
|
|
) {
|
|
router.replace({
|
|
pathname: location.pathname,
|
|
query: omit(queryParams, "team_id"),
|
|
});
|
|
}
|
|
}
|
|
const { setResetSelectedRows } = useContext(TableContext);
|
|
|
|
const hostHiddenColumns = localStorage.getItem("hostHiddenColumns");
|
|
const storedHiddenColumns = hostHiddenColumns
|
|
? JSON.parse(hostHiddenColumns)
|
|
: null;
|
|
|
|
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 = (() => {
|
|
let query = "";
|
|
|
|
if (queryParams && queryParams.query) {
|
|
query = queryParams.query;
|
|
}
|
|
|
|
return query;
|
|
})();
|
|
|
|
// ========= 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 [hosts, setHosts] = useState<IHost[]>();
|
|
const [isHostsLoading, setIsHostsLoading] = useState(false);
|
|
const [hasHostErrors, setHasHostErrors] = useState(false);
|
|
const [filteredHostCount, setFilteredHostCount] = useState<number>();
|
|
const [isHostCountLoading, setIsHostCountLoading] = useState(false);
|
|
const [hasHostCountErrors, setHasHostCountErrors] = useState(false);
|
|
const [sortBy, setSortBy] = useState<ISortOption[]>(initialSortBy);
|
|
const [policy, setPolicy] = useState<IPolicy>();
|
|
const [softwareDetails, setSoftwareDetails] = useState<ISoftware | null>(
|
|
null
|
|
);
|
|
const [
|
|
mdmSolutionDetails,
|
|
setMDMSolutionDetails,
|
|
] = useState<IMdmSolution | null>(null);
|
|
const [
|
|
munkiIssueDetails,
|
|
setMunkiIssueDetails,
|
|
] = useState<IMunkiIssuesAggregate | null>(null);
|
|
const [tableQueryData, setTableQueryData] = useState<ITableQueryProps>();
|
|
const [
|
|
currentQueryOptions,
|
|
setCurrentQueryOptions,
|
|
] = useState<ILoadHostsOptions>();
|
|
const [resetPageIndex, setResetPageIndex] = useState<boolean>(false);
|
|
const [isUpdatingLabel, setIsUpdatingLabel] = useState<boolean>(false);
|
|
const [isUpdatingSecret, setIsUpdatingSecret] = useState<boolean>(false);
|
|
const [isUpdatingHosts, setIsUpdatingHosts] = useState<boolean>(false);
|
|
|
|
// ======== end states
|
|
const routeTemplate = route?.path ?? "";
|
|
const policyId = queryParams?.policy_id;
|
|
const policyResponse: PolicyResponse = queryParams?.policy_response;
|
|
const softwareId =
|
|
queryParams?.software_id !== undefined
|
|
? parseInt(queryParams.software_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_id: osId, os_name: osName, os_version: osVersion } = queryParams;
|
|
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 { active_label: activeLabel, label_id: labelID } = routeParams;
|
|
|
|
// ===== filter matching
|
|
const selectedFilters: string[] = [];
|
|
labelID && selectedFilters.push(`${LABEL_SLUG_PREFIX}${labelID}`);
|
|
activeLabel && selectedFilters.push(activeLabel);
|
|
// ===== end filter matching
|
|
|
|
const canEnrollHosts =
|
|
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
|
|
const canEnrollGlobalHosts = isGlobalAdmin || isGlobalMaintainer;
|
|
const canAddNewLabels = (isGlobalAdmin || isGlobalMaintainer) ?? false;
|
|
|
|
const {
|
|
isLoading: isLoadingLabels,
|
|
data: labels,
|
|
error: labelsError,
|
|
refetch: refetchLabels,
|
|
} = useQuery<ILabelsResponse, Error, ILabel[]>(
|
|
["labels"],
|
|
() => labelsAPI.loadAll(),
|
|
{
|
|
select: (data: ILabelsResponse) => data.labels,
|
|
}
|
|
);
|
|
|
|
const {
|
|
isLoading: isGlobalSecretsLoading,
|
|
data: globalSecrets,
|
|
refetch: refetchGlobalSecrets,
|
|
} = useQuery<IEnrollSecretsResponse, Error, IEnrollSecret[]>(
|
|
["global secrets"],
|
|
() => enrollSecretsAPI.getGlobalEnrollSecrets(),
|
|
{
|
|
enabled: !!canEnrollGlobalHosts,
|
|
select: (data: IEnrollSecretsResponse) => data.secrets,
|
|
}
|
|
);
|
|
|
|
const {
|
|
isLoading: isTeamSecretsLoading,
|
|
data: teamSecrets,
|
|
refetch: refetchTeamSecrets,
|
|
} = useQuery<IEnrollSecretsResponse, Error, IEnrollSecret[]>(
|
|
["team secrets", currentTeam],
|
|
() => {
|
|
if (currentTeam) {
|
|
return enrollSecretsAPI.getTeamEnrollSecrets(currentTeam.id);
|
|
}
|
|
return { secrets: [] };
|
|
},
|
|
{
|
|
enabled: !!currentTeam?.id && !!canEnrollHosts,
|
|
select: (data: IEnrollSecretsResponse) => data.secrets,
|
|
}
|
|
);
|
|
|
|
const {
|
|
data: teams,
|
|
isLoading: isLoadingTeams,
|
|
refetch: refetchTeams,
|
|
} = useQuery<ILoadTeamsResponse, Error, ITeam[]>(
|
|
["teams"],
|
|
() => teamsAPI.loadAll(),
|
|
{
|
|
enabled: !!isPremiumTier,
|
|
select: (data: ILoadTeamsResponse) =>
|
|
data.teams.sort((a, b) => sortUtils.caseInsensitiveAsc(a.name, b.name)),
|
|
onSuccess: (responseTeams: ITeam[]) => {
|
|
setAvailableTeams(responseTeams);
|
|
if (
|
|
responseTeams.filter(
|
|
(responseTeam) => responseTeam.id === currentTeam?.id
|
|
)
|
|
) {
|
|
setCurrentTeam(undefined);
|
|
}
|
|
if (!currentTeam && !isOnGlobalTeam && responseTeams.length) {
|
|
setCurrentTeam(responseTeams[0]);
|
|
}
|
|
},
|
|
}
|
|
);
|
|
|
|
useQuery<IStoredPolicyResponse, Error>(
|
|
["policy"],
|
|
() => globalPoliciesAPI.load(policyId),
|
|
{
|
|
enabled: !!policyId,
|
|
onSuccess: ({ policy: policyAPIResponse }) => {
|
|
setPolicy(policyAPIResponse);
|
|
},
|
|
}
|
|
);
|
|
|
|
const { data: osVersions } = useQuery<
|
|
IOSVersionsResponse,
|
|
Error,
|
|
IOperatingSystemVersion[],
|
|
IGetOSVersionsQueryKey[]
|
|
>([{ scope: "os_versions" }], () => getOSVersions(), {
|
|
enabled:
|
|
!!queryParams?.os_id ||
|
|
(!!queryParams?.os_name && !!queryParams?.os_version),
|
|
keepPreviousData: true,
|
|
select: (data) => data.os_versions,
|
|
});
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
const retrieveHosts = async (options: ILoadHostsOptions = {}) => {
|
|
setIsHostsLoading(true);
|
|
options = {
|
|
...options,
|
|
teamId: getValidatedTeamId(
|
|
availableTeams || [],
|
|
options.teamId as number,
|
|
currentUser,
|
|
isOnGlobalTeam as boolean
|
|
),
|
|
};
|
|
|
|
if (queryParams.team_id) {
|
|
options.teamId = queryParams.team_id;
|
|
}
|
|
|
|
try {
|
|
const {
|
|
hosts: returnedHosts,
|
|
software,
|
|
mobile_device_management_solution,
|
|
munki_issue,
|
|
} = await hostsAPI.loadHosts(options);
|
|
setHosts(returnedHosts);
|
|
software && setSoftwareDetails(software);
|
|
mobile_device_management_solution &&
|
|
setMDMSolutionDetails(mobile_device_management_solution);
|
|
munki_issue && setMunkiIssueDetails(munki_issue);
|
|
} catch (error) {
|
|
console.error(error);
|
|
setHasHostErrors(true);
|
|
} finally {
|
|
setIsHostsLoading(false);
|
|
}
|
|
};
|
|
|
|
const retrieveHostCount = async (options: IHostCountLoadOptions = {}) => {
|
|
setIsHostCountLoading(true);
|
|
|
|
options = {
|
|
...options,
|
|
teamId: getValidatedTeamId(
|
|
availableTeams || [],
|
|
options.teamId as number,
|
|
currentUser,
|
|
isOnGlobalTeam as boolean
|
|
),
|
|
};
|
|
|
|
if (queryParams.team_id) {
|
|
options.teamId = queryParams.team_id;
|
|
}
|
|
try {
|
|
const { count: returnedHostCount } = await hostCountAPI.load(options);
|
|
setFilteredHostCount(returnedHostCount);
|
|
} catch (error) {
|
|
console.error(error);
|
|
setHasHostCountErrors(true);
|
|
} finally {
|
|
setIsHostCountLoading(false);
|
|
}
|
|
};
|
|
|
|
const refetchHosts = (options: ILoadHostsOptions) => {
|
|
retrieveHosts(options);
|
|
if (options.sortBy) {
|
|
delete options.sortBy;
|
|
}
|
|
retrieveHostCount(omit(options, "device_mapping"));
|
|
};
|
|
|
|
let teamSync = false;
|
|
if (currentUser && availableTeams) {
|
|
const teamIdParam = queryParams.team_id
|
|
? parseInt(queryParams.team_id, 10) // we don't want to parse undefined so we can differntiate non-numeric strings as NaN
|
|
: undefined;
|
|
if (currentTeam?.id && !teamIdParam) {
|
|
teamSync = true;
|
|
} else if (teamIdParam === currentTeam?.id) {
|
|
teamSync = true;
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const teamId = parseInt(queryParams?.team_id, 10) || 0;
|
|
const selectedTeam = find(availableTeams, ["id", teamId]);
|
|
if (selectedTeam) {
|
|
setCurrentTeam(selectedTeam);
|
|
}
|
|
setShowNoEnrollSecretBanner(true);
|
|
|
|
const slugToFind =
|
|
(selectedFilters.length > 0 &&
|
|
selectedFilters.find((f) => f.includes(LABEL_SLUG_PREFIX))) ||
|
|
selectedFilters[0];
|
|
|
|
const validLabel = find(labels, ["slug", slugToFind]) as ILabel;
|
|
|
|
setSelectedLabel(validLabel);
|
|
|
|
const options: ILoadHostsOptions = {
|
|
selectedLabels: selectedFilters,
|
|
globalFilter: searchQuery,
|
|
sortBy,
|
|
teamId: selectedTeam?.id,
|
|
policyId,
|
|
policyResponse,
|
|
softwareId,
|
|
status,
|
|
mdmId,
|
|
mdmEnrollmentStatus,
|
|
munkiIssueId,
|
|
lowDiskSpaceHosts,
|
|
osId,
|
|
osName,
|
|
osVersion,
|
|
page: tableQueryData ? tableQueryData.pageIndex : 0,
|
|
perPage: tableQueryData ? tableQueryData.pageSize : 100,
|
|
device_mapping: true,
|
|
};
|
|
|
|
if (isEqual(options, currentQueryOptions)) {
|
|
return;
|
|
}
|
|
|
|
if (teamSync) {
|
|
retrieveHosts(options);
|
|
retrieveHostCount(omit(options, "device_mapping"));
|
|
setCurrentQueryOptions(options);
|
|
}
|
|
}, [availableTeams, currentTeam, location, labels]);
|
|
|
|
const isLastPage =
|
|
tableQueryData &&
|
|
!!filteredHostCount &&
|
|
DEFAULT_PAGE_SIZE * tableQueryData.pageIndex + (hosts?.length || 0) >=
|
|
filteredHostCount;
|
|
|
|
const handleLabelChange = ({ slug }: ILabel): boolean => {
|
|
const { MANAGE_HOSTS } = PATHS;
|
|
|
|
// Non-status labels are not compatible with policies or software filters
|
|
// so omit policies and software params from next location
|
|
let newQueryParams = queryParams;
|
|
if (slug) {
|
|
newQueryParams = omit(newQueryParams, [
|
|
"policy_id",
|
|
"policy_response",
|
|
"software_id",
|
|
]);
|
|
}
|
|
|
|
router.replace(
|
|
getNextLocationPath({
|
|
pathPrefix: `${MANAGE_HOSTS}/${slug}`,
|
|
queryParams: newQueryParams,
|
|
})
|
|
);
|
|
|
|
return true;
|
|
};
|
|
|
|
// NOTE: used to reset page number to 0 when modifying filters
|
|
const handleResetPageIndex = () => {
|
|
setTableQueryData({
|
|
...tableQueryData,
|
|
pageIndex: 0,
|
|
} as ITableQueryProps);
|
|
|
|
setResetPageIndex(true);
|
|
};
|
|
|
|
const handleChangePoliciesFilter = (response: PolicyResponse) => {
|
|
handleResetPageIndex();
|
|
|
|
router.replace(
|
|
getNextLocationPath({
|
|
pathPrefix: PATHS.MANAGE_HOSTS,
|
|
routeTemplate,
|
|
routeParams,
|
|
queryParams: Object.assign({}, queryParams, {
|
|
policy_id: policyId,
|
|
policy_response: response,
|
|
}),
|
|
})
|
|
);
|
|
};
|
|
|
|
const handleClearRouteParam = () => {
|
|
handleResetPageIndex();
|
|
|
|
router.replace(
|
|
getNextLocationPath({
|
|
pathPrefix: PATHS.MANAGE_HOSTS,
|
|
routeTemplate,
|
|
routeParams: undefined,
|
|
queryParams,
|
|
})
|
|
);
|
|
};
|
|
|
|
const handleClearFilter = (omitParams: string[]) => {
|
|
handleResetPageIndex();
|
|
|
|
router.replace(
|
|
getNextLocationPath({
|
|
pathPrefix: PATHS.MANAGE_HOSTS,
|
|
routeTemplate,
|
|
routeParams,
|
|
queryParams: omit(queryParams, omitParams),
|
|
})
|
|
);
|
|
};
|
|
|
|
const handleClearPoliciesFilter = () => {
|
|
handleClearFilter(["policy_id", "policy_response"]);
|
|
};
|
|
|
|
const handleClearOSFilter = () => {
|
|
handleClearFilter(["os_id", "os_name", "os_version"]);
|
|
};
|
|
|
|
const handleClearSoftwareFilter = () => {
|
|
handleClearFilter(["software_id"]);
|
|
};
|
|
|
|
const handleClearMDMSolutionFilter = () => {
|
|
handleClearFilter(["mdm_id"]);
|
|
};
|
|
|
|
const handleClearMDMEnrollmentFilter = () => {
|
|
handleClearFilter(["mdm_enrollment_status"]);
|
|
};
|
|
|
|
const handleClearMunkiIssueFilter = () => {
|
|
handleClearFilter(["munki_issue_id"]);
|
|
};
|
|
|
|
const handleClearLowDiskSpaceFilter = () => {
|
|
handleClearFilter(["low_disk_space"]);
|
|
};
|
|
|
|
const handleTeamSelect = (teamId: number) => {
|
|
const { MANAGE_HOSTS } = PATHS;
|
|
|
|
const teamIdParam = getValidatedTeamId(
|
|
availableTeams || [],
|
|
teamId,
|
|
currentUser,
|
|
isOnGlobalTeam ?? false
|
|
);
|
|
|
|
const slimmerParams = omit(queryParams, ["team_id"]);
|
|
|
|
const newQueryParams = !teamIdParam
|
|
? slimmerParams
|
|
: Object.assign(slimmerParams, { team_id: teamIdParam });
|
|
|
|
const nextLocation = getNextLocationPath({
|
|
pathPrefix: MANAGE_HOSTS,
|
|
routeTemplate,
|
|
routeParams,
|
|
queryParams: newQueryParams,
|
|
});
|
|
|
|
handleResetPageIndex();
|
|
router.replace(nextLocation);
|
|
const selectedTeam = find(availableTeams, ["id", teamId]);
|
|
setCurrentTeam(selectedTeam);
|
|
};
|
|
|
|
const handleStatusDropdownChange = (statusName: string) => {
|
|
handleResetPageIndex();
|
|
|
|
router.replace(
|
|
getNextLocationPath({
|
|
pathPrefix: PATHS.MANAGE_HOSTS,
|
|
routeTemplate,
|
|
routeParams,
|
|
queryParams: { ...queryParams, status: statusName },
|
|
})
|
|
);
|
|
};
|
|
|
|
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(() => {
|
|
setResetPageIndex(false);
|
|
}, [queryParams]);
|
|
|
|
// NOTE: this is called once on initial render and every time the query changes
|
|
const onTableQueryChange = useCallback(
|
|
async (newTableQuery: ITableQueryProps) => {
|
|
if (isEqual(newTableQuery, tableQueryData)) {
|
|
return;
|
|
}
|
|
|
|
setTableQueryData({ ...newTableQuery });
|
|
|
|
const {
|
|
searchQuery: searchText,
|
|
sortHeader,
|
|
sortDirection,
|
|
} = newTableQuery;
|
|
|
|
let sort = sortBy;
|
|
if (sortHeader) {
|
|
sort = [
|
|
{
|
|
key: sortHeader,
|
|
direction: sortDirection || 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 } = {};
|
|
if (!isEmpty(searchText)) {
|
|
newQueryParams.query = searchText;
|
|
}
|
|
|
|
newQueryParams.order_key = sort[0].key || DEFAULT_SORT_HEADER;
|
|
newQueryParams.order_direction =
|
|
sort[0].direction || DEFAULT_SORT_DIRECTION;
|
|
|
|
if (currentTeam) {
|
|
newQueryParams.team_id = currentTeam.id;
|
|
}
|
|
|
|
if (status) {
|
|
newQueryParams.status = status;
|
|
}
|
|
if (policyId && policyResponse) {
|
|
newQueryParams.policy_id = policyId;
|
|
newQueryParams.policy_response = policyResponse;
|
|
} else if (softwareId) {
|
|
newQueryParams.software_id = softwareId;
|
|
} 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 (osId || (osName && osVersion)) {
|
|
newQueryParams.os_id = osId;
|
|
newQueryParams.os_name = osName;
|
|
newQueryParams.os_version = osVersion;
|
|
}
|
|
|
|
router.replace(
|
|
getNextLocationPath({
|
|
pathPrefix: PATHS.MANAGE_HOSTS,
|
|
routeTemplate,
|
|
routeParams,
|
|
queryParams: newQueryParams,
|
|
})
|
|
);
|
|
|
|
return 0;
|
|
},
|
|
[
|
|
availableTeams,
|
|
currentTeam,
|
|
currentUser,
|
|
policyId,
|
|
queryParams,
|
|
softwareId,
|
|
status,
|
|
mdmId,
|
|
mdmEnrollmentStatus,
|
|
munkiIssueId,
|
|
lowDiskSpaceHosts,
|
|
osId,
|
|
osName,
|
|
osVersion,
|
|
sortBy,
|
|
]
|
|
);
|
|
|
|
const onSaveSecret = async (enrollSecretString: string) => {
|
|
const { MANAGE_HOSTS } = PATHS;
|
|
|
|
// Creates new list of secrets removing selected secret and adding new secret
|
|
const currentSecrets = currentTeam
|
|
? teamSecrets || []
|
|
: globalSecrets || [];
|
|
|
|
const newSecrets = currentSecrets.filter(
|
|
(s) => s.secret !== selectedSecret?.secret
|
|
);
|
|
|
|
if (enrollSecretString) {
|
|
newSecrets.push({ secret: enrollSecretString });
|
|
}
|
|
|
|
setIsUpdatingSecret(true);
|
|
|
|
try {
|
|
if (currentTeam?.id) {
|
|
await enrollSecretsAPI.modifyTeamEnrollSecrets(
|
|
currentTeam.id,
|
|
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 = currentTeam
|
|
? teamSecrets || []
|
|
: globalSecrets || [];
|
|
|
|
const newSecrets = currentSecrets.filter(
|
|
(s) => s.secret !== selectedSecret?.secret
|
|
);
|
|
|
|
setIsUpdatingSecret(true);
|
|
|
|
try {
|
|
if (currentTeam?.id) {
|
|
await enrollSecretsAPI.modifyTeamEnrollSecrets(
|
|
currentTeam.id,
|
|
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);
|
|
};
|
|
|
|
const onTransferHostSubmit = async (transferTeam: ITeam) => {
|
|
setIsUpdatingHosts(true);
|
|
|
|
const teamId = typeof transferTeam.id === "number" ? transferTeam.id : null;
|
|
let action = hostsAPI.transferToTeam(teamId, selectedHostIds);
|
|
|
|
if (isAllMatchingHostsSelected) {
|
|
const labelId = selectedLabel?.id;
|
|
|
|
action = hostsAPI.transferToTeamByFilter({
|
|
teamId,
|
|
query: searchQuery,
|
|
status,
|
|
labelId,
|
|
});
|
|
}
|
|
|
|
try {
|
|
await action;
|
|
|
|
const successMessage =
|
|
teamId === null
|
|
? `Hosts successfully removed from teams.`
|
|
: `Hosts successfully transferred to ${transferTeam.name}.`;
|
|
|
|
renderFlash("success", successMessage);
|
|
setResetSelectedRows(true);
|
|
refetchHosts({
|
|
selectedLabels: selectedFilters,
|
|
globalFilter: searchQuery,
|
|
sortBy,
|
|
teamId: currentTeam?.id,
|
|
policyId,
|
|
policyResponse,
|
|
softwareId,
|
|
status,
|
|
mdmId,
|
|
mdmEnrollmentStatus,
|
|
munkiIssueId,
|
|
lowDiskSpaceHosts,
|
|
osId,
|
|
osName,
|
|
osVersion,
|
|
});
|
|
|
|
toggleTransferHostModal();
|
|
setSelectedHostIds([]);
|
|
setIsAllMatchingHostsSelected(false);
|
|
} catch (error) {
|
|
renderFlash("error", "Could not transfer hosts. Please try again.");
|
|
} finally {
|
|
setIsUpdatingHosts(false);
|
|
}
|
|
};
|
|
|
|
const onDeleteHostSubmit = async () => {
|
|
setIsUpdatingHosts(true);
|
|
|
|
let action = hostsAPI.destroyBulk(selectedHostIds);
|
|
|
|
if (isAllMatchingHostsSelected) {
|
|
const teamId = currentTeam?.id || null;
|
|
|
|
const labelId = selectedLabel?.id;
|
|
|
|
action = hostsAPI.destroyByFilter({
|
|
teamId,
|
|
query: searchQuery,
|
|
status,
|
|
labelId,
|
|
});
|
|
}
|
|
|
|
try {
|
|
await action;
|
|
|
|
const successMessage = `${
|
|
selectedHostIds.length === 1 ? "Host" : "Hosts"
|
|
} successfully deleted.`;
|
|
|
|
renderFlash("success", successMessage);
|
|
setResetSelectedRows(true);
|
|
refetchHosts({
|
|
selectedLabels: selectedFilters,
|
|
globalFilter: searchQuery,
|
|
sortBy,
|
|
teamId: currentTeam?.id,
|
|
policyId,
|
|
policyResponse,
|
|
softwareId,
|
|
status,
|
|
mdmId,
|
|
mdmEnrollmentStatus,
|
|
munkiIssueId,
|
|
lowDiskSpaceHosts,
|
|
osId,
|
|
osName,
|
|
osVersion,
|
|
});
|
|
|
|
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={availableTeams || []}
|
|
selectedTeamId={currentTeam?.id}
|
|
isDisabled={isHostsLoading || isHostCountLoading}
|
|
onChange={(newSelectedValue: number) =>
|
|
handleTeamSelect(newSelectedValue)
|
|
}
|
|
/>
|
|
);
|
|
|
|
const renderLabelFilterPill = () => {
|
|
if (selectedLabel) {
|
|
const { description, display_text, label_type } = selectedLabel;
|
|
const pillLabel =
|
|
PLATFORM_LABEL_DISPLAY_NAMES[display_text] ?? display_text;
|
|
|
|
return (
|
|
<>
|
|
<FilterPill
|
|
label={pillLabel}
|
|
tooltipDescription={description}
|
|
onClear={handleClearRouteParam}
|
|
/>
|
|
{label_type !== "builtin" && !isOnlyObserver && (
|
|
<>
|
|
<Button onClick={onEditLabelClick} variant={"text-icon"}>
|
|
<img src={PencilIcon} alt="Edit label" />
|
|
</Button>
|
|
<Button onClick={toggleDeleteLabelModal} variant={"text-icon"}>
|
|
<img src={TrashIcon} alt="Delete label" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const renderOSFilterBlock = () => {
|
|
if (!osId && !(osName && osVersion)) return null;
|
|
|
|
let os: IOperatingSystemVersion | undefined;
|
|
if (osId) {
|
|
os = osVersions?.find((v) => v.os_id === osId);
|
|
} else if (osName && osVersion) {
|
|
const name: string = osName;
|
|
const vers: string = osVersion;
|
|
|
|
os = osVersions?.find(
|
|
({ name_only, version }) =>
|
|
name_only.toLowerCase() === name.toLowerCase() &&
|
|
version.toLowerCase() === vers.toLowerCase()
|
|
);
|
|
}
|
|
if (!os) return null;
|
|
|
|
const { name, name_only, version } = os;
|
|
const label = formatOperatingSystemDisplayName(
|
|
name_only || version
|
|
? `${name_only || ""} ${version || ""}`
|
|
: `${name || ""}`
|
|
);
|
|
const TooltipDescription = (
|
|
<span className={`tooltip__tooltip-text`}>
|
|
{`Hosts with ${formatOperatingSystemDisplayName(name_only || name)}`},
|
|
<br />
|
|
{version && `${version} installed`}
|
|
</span>
|
|
);
|
|
|
|
return (
|
|
<FilterPill
|
|
label={label}
|
|
tooltipDescription={TooltipDescription}
|
|
onClear={handleClearOSFilter}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const renderPoliciesFilterBlock = () => (
|
|
<>
|
|
<PoliciesFilter
|
|
policyResponse={policyResponse}
|
|
onChange={handleChangePoliciesFilter}
|
|
/>
|
|
<FilterPill
|
|
icon={PolicyIcon}
|
|
label={policy?.name ?? "..."}
|
|
onClear={handleClearPoliciesFilter}
|
|
className={`${baseClass}__policies-filter-pill`}
|
|
/>
|
|
</>
|
|
);
|
|
|
|
const renderSoftwareFilterBlock = () => {
|
|
if (!softwareDetails) return null;
|
|
|
|
const { name, version } = softwareDetails;
|
|
const label = `${name || "Unknown software"} ${version || ""}`;
|
|
|
|
const TooltipDescription = (
|
|
<span className={`tooltip__tooltip-text`}>
|
|
Hosts with {name || "Unknown software"},
|
|
<br />
|
|
{version || "version unknown"} installed
|
|
</span>
|
|
);
|
|
|
|
return (
|
|
<FilterPill
|
|
label={label}
|
|
onClear={handleClearSoftwareFilter}
|
|
tooltipDescription={TooltipDescription}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const renderMDMSolutionFilterBlock = () => {
|
|
if (!mdmSolutionDetails) return null;
|
|
|
|
const { name, server_url } = mdmSolutionDetails;
|
|
const label = name ? `${name} ${server_url}` : `${server_url}`;
|
|
|
|
const TooltipDescription = (
|
|
<span className={`tooltip__tooltip-text`}>
|
|
Host enrolled
|
|
{name !== "Unknown" && ` to ${name}`}
|
|
<br /> at {server_url}
|
|
</span>
|
|
);
|
|
|
|
return (
|
|
<FilterPill
|
|
label={label}
|
|
tooltipDescription={TooltipDescription}
|
|
onClear={handleClearMDMSolutionFilter}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const renderMDMEnrollmentFilterBlock = () => {
|
|
if (!mdmEnrollmentStatus) return null;
|
|
|
|
let label: string;
|
|
switch (mdmEnrollmentStatus) {
|
|
case "automatic":
|
|
label = "MDM enrolled (automatic)";
|
|
break;
|
|
case "manual":
|
|
label = "MDM enrolled (manual)";
|
|
break;
|
|
default:
|
|
label = "Unenrolled";
|
|
}
|
|
|
|
let TooltipDescription: JSX.Element;
|
|
switch (mdmEnrollmentStatus) {
|
|
case "automatic":
|
|
TooltipDescription = (
|
|
<span className={`tooltip__tooltip-text`}>
|
|
Hosts automatically enrolled <br />
|
|
to an MDM solution the first time <br />
|
|
the host is used. Administrators <br />
|
|
might have a higher level of control <br />
|
|
over these hosts.
|
|
</span>
|
|
);
|
|
break;
|
|
case "manual":
|
|
TooltipDescription = (
|
|
<span className={`tooltip__tooltip-text`}>
|
|
Hosts manually enrolled to an <br />
|
|
MDM solution by a user or <br />
|
|
administrator.
|
|
</span>
|
|
);
|
|
break;
|
|
default:
|
|
TooltipDescription = (
|
|
<span className={`tooltip__tooltip-text`}>
|
|
Hosts not enrolled to <br /> an MDM solution.
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<FilterPill
|
|
label={label}
|
|
tooltipDescription={TooltipDescription}
|
|
onClear={handleClearMDMEnrollmentFilter}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const renderMunkiIssueFilterBlock = () => {
|
|
if (munkiIssueDetails) {
|
|
return (
|
|
<FilterPill
|
|
label={munkiIssueDetails.name}
|
|
tooltipDescription={
|
|
<span className={`tooltip__tooltip-text`}>
|
|
Hosts that reported this Munki issue <br />
|
|
the last time Munki ran on each host.
|
|
</span>
|
|
}
|
|
onClear={handleClearMunkiIssueFilter}
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const renderLowDiskSpaceFilterBlock = () => {
|
|
const TooltipDescription = (
|
|
<span className={`tooltip__tooltip-text`}>
|
|
Hosts that have {lowDiskSpaceHosts} GB or less <br />
|
|
disk space available.
|
|
</span>
|
|
);
|
|
|
|
return (
|
|
<FilterPill
|
|
label="Low disk space"
|
|
tooltipDescription={TooltipDescription}
|
|
onClear={handleClearLowDiskSpaceFilter}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const renderEditColumnsModal = () => {
|
|
if (!config || !currentUser) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<EditColumnsModal
|
|
columns={generateAvailableTableHeaders(
|
|
config,
|
|
currentUser,
|
|
currentTeam
|
|
)}
|
|
hiddenColumns={hiddenColumns}
|
|
onSaveColumns={onSaveColumns}
|
|
onCancelColumns={toggleEditColumnsModal}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const renderSecretEditorModal = () => (
|
|
<SecretEditorModal
|
|
selectedTeam={currentTeam?.id || 0}
|
|
teams={teams || []}
|
|
onSaveSecret={onSaveSecret}
|
|
toggleSecretEditorModal={toggleSecretEditorModal}
|
|
selectedSecret={selectedSecret}
|
|
isUpdatingSecret={isUpdatingSecret}
|
|
/>
|
|
);
|
|
|
|
const renderDeleteSecretModal = () => (
|
|
<DeleteSecretModal
|
|
onDeleteSecret={onDeleteSecret}
|
|
selectedTeam={currentTeam?.id || 0}
|
|
teams={teams || []}
|
|
toggleDeleteSecretModal={toggleDeleteSecretModal}
|
|
isUpdatingSecret={isUpdatingSecret}
|
|
/>
|
|
);
|
|
|
|
const renderEnrollSecretModal = () => (
|
|
<EnrollSecretModal
|
|
selectedTeam={currentTeam?.id || 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.
|
|
currentTeam && !isSandboxMode
|
|
? teamSecrets?.[0].secret
|
|
: globalSecrets?.[0].secret;
|
|
return (
|
|
<AddHostsModal
|
|
currentTeam={currentTeam}
|
|
enrollSecret={enrollSecret}
|
|
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}
|
|
isUpdatingHosts={isUpdatingHosts}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const renderDeleteHostModal = () => (
|
|
<DeleteHostModal
|
|
selectedHostIds={selectedHostIds}
|
|
onSubmit={onDeleteHostSubmit}
|
|
onCancel={toggleDeleteHostModal}
|
|
isAllMatchingHostsSelected={isAllMatchingHostsSelected}
|
|
isUpdatingHosts={isUpdatingHosts}
|
|
/>
|
|
);
|
|
|
|
const renderHeader = () => (
|
|
<div className={`${baseClass}__header`}>
|
|
<div className={`${baseClass}__text`}>
|
|
<div className={`${baseClass}__title`}>
|
|
{isFreeTier && <h1>Hosts</h1>}
|
|
{isPremiumTier &&
|
|
availableTeams &&
|
|
(availableTeams.length > 1 || isOnGlobalTeam) &&
|
|
renderTeamsFilterDropdown()}
|
|
{isPremiumTier &&
|
|
!isOnGlobalTeam &&
|
|
availableTeams &&
|
|
availableTeams.length === 1 && <h1>{availableTeams[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(
|
|
currentHiddenColumns,
|
|
config,
|
|
currentUser,
|
|
currentTeam
|
|
);
|
|
|
|
const columnAccessors = tableColumns
|
|
.map((column) => (column.accessor ? column.accessor : ""))
|
|
.filter((element) => element);
|
|
visibleColumns = columnAccessors.join(",");
|
|
}
|
|
|
|
let options = {
|
|
selectedLabels: selectedFilters,
|
|
globalFilter: searchQuery,
|
|
sortBy,
|
|
teamId: currentTeam?.id,
|
|
policyId,
|
|
policyResponse,
|
|
softwareId,
|
|
status,
|
|
mdmId,
|
|
mdmEnrollmentStatus,
|
|
munkiIssueId,
|
|
lowDiskSpaceHosts,
|
|
os_id: osId,
|
|
os_name: osName,
|
|
os_version: osVersion,
|
|
visibleColumns,
|
|
};
|
|
|
|
options = {
|
|
...options,
|
|
teamId: getValidatedTeamId(
|
|
availableTeams || [],
|
|
options.teamId as number,
|
|
currentUser,
|
|
isOnGlobalTeam as boolean
|
|
),
|
|
};
|
|
|
|
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 = filteredHostCount;
|
|
|
|
return (
|
|
<div
|
|
className={`${baseClass}__count ${
|
|
isHostCountLoading ? "count-loading" : ""
|
|
}`}
|
|
>
|
|
{count !== undefined && (
|
|
<span>{`${count} host${count === 1 ? "" : "s"}`}</span>
|
|
)}
|
|
{!!count && (
|
|
<Button
|
|
className={`${baseClass}__export-btn`}
|
|
onClick={onExportHostsResults}
|
|
variant="text-link"
|
|
>
|
|
<>
|
|
Export hosts <img alt="" src={DownloadIcon} />
|
|
</>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}, [isHostCountLoading, filteredHostCount]);
|
|
|
|
const renderActiveFilterBlock = () => {
|
|
const showSelectedLabel = selectedLabel && selectedLabel.type !== "all";
|
|
|
|
if (
|
|
showSelectedLabel ||
|
|
policyId ||
|
|
softwareId ||
|
|
showSelectedLabel ||
|
|
mdmId ||
|
|
mdmEnrollmentStatus ||
|
|
lowDiskSpaceHosts ||
|
|
osId ||
|
|
(osName && osVersion) ||
|
|
munkiIssueId
|
|
) {
|
|
const renderFilterPill = () => {
|
|
switch (true) {
|
|
// backend allows for pill combos label x low disk space
|
|
case showSelectedLabel && !!lowDiskSpaceHosts:
|
|
return (
|
|
<>
|
|
{renderLabelFilterPill()} {renderLowDiskSpaceFilterBlock()}
|
|
</>
|
|
);
|
|
case showSelectedLabel:
|
|
return renderLabelFilterPill();
|
|
case !!policyId:
|
|
return renderPoliciesFilterBlock();
|
|
case !!softwareId:
|
|
return renderSoftwareFilterBlock();
|
|
case !!mdmId:
|
|
return renderMDMSolutionFilterBlock();
|
|
case !!mdmEnrollmentStatus:
|
|
return renderMDMEnrollmentFilterBlock();
|
|
case !!osId || (!!osName && !!osVersion):
|
|
return renderOSFilterBlock();
|
|
case !!munkiIssueId:
|
|
return renderMunkiIssueFilterBlock();
|
|
case !!lowDiskSpaceHosts:
|
|
return renderLowDiskSpaceFilterBlock();
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={`${baseClass}__labels-active-filter-wrap`}>
|
|
{renderFilterPill()}
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
const renderCustomControls = () => {
|
|
// we filter out the status labels as we dont want to display them in the label
|
|
// filter select dropdown.
|
|
// TODO: seperate labels and status into different data sets.
|
|
const selectedDropdownLabel =
|
|
selectedLabel?.type !== "all" && selectedLabel?.type !== "status"
|
|
? selectedLabel
|
|
: undefined;
|
|
|
|
return (
|
|
<div className={`${baseClass}__filter-dropdowns`}>
|
|
<Dropdown
|
|
value={status || ""}
|
|
className={`${baseClass}__status_dropdown`}
|
|
options={HOST_SELECT_STATUSES}
|
|
searchable={false}
|
|
onChange={handleStatusDropdownChange}
|
|
/>
|
|
<LabelFilterSelect
|
|
className={`${baseClass}__label-filter-dropdown`}
|
|
labels={labels ?? []}
|
|
canAddNewLabels={canAddNewLabels}
|
|
selectedLabel={selectedDropdownLabel ?? null}
|
|
onChange={handleLabelChange}
|
|
onAddLabel={onAddLabelClick}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderTable = () => {
|
|
if (!config || !currentUser || !hosts || !teamSync) {
|
|
return <Spinner />;
|
|
}
|
|
|
|
if (hasHostErrors || hasHostCountErrors) {
|
|
return <TableDataError />;
|
|
}
|
|
|
|
// There are no hosts for this instance yet
|
|
if (
|
|
filteredHostCount === 0 &&
|
|
searchQuery === "" &&
|
|
teamSync &&
|
|
!labelID &&
|
|
!status
|
|
) {
|
|
const {
|
|
software_id,
|
|
policy_id,
|
|
mdm_id,
|
|
mdm_enrollment_status,
|
|
low_disk_space,
|
|
} = queryParams || {};
|
|
const includesNameCardFilter = !!(
|
|
software_id ||
|
|
policy_id ||
|
|
mdm_id ||
|
|
mdm_enrollment_status ||
|
|
low_disk_space ||
|
|
osId ||
|
|
osName ||
|
|
osVersion
|
|
);
|
|
|
|
return (
|
|
<NoHosts
|
|
toggleAddHostsModal={toggleAddHostsModal}
|
|
canEnrollHosts={canEnrollHosts}
|
|
includesNameCardFilter={includesNameCardFilter}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const secondarySelectActions: IActionButtonProps[] = [
|
|
{
|
|
name: "transfer",
|
|
onActionButtonClick: onTransferToTeamClick,
|
|
buttonText: "Transfer",
|
|
variant: "text-icon",
|
|
icon: "transfer",
|
|
hideButton: !isPremiumTier || (!isGlobalAdmin && !isGlobalMaintainer),
|
|
},
|
|
];
|
|
|
|
const tableColumns = generateVisibleTableColumns(
|
|
hiddenColumns,
|
|
config,
|
|
currentUser,
|
|
currentTeam
|
|
);
|
|
|
|
return (
|
|
<TableContainer
|
|
columns={tableColumns}
|
|
data={hosts}
|
|
isLoading={isHostsLoading || isHostCountLoading}
|
|
manualSortBy
|
|
defaultSortHeader={(sortBy[0] && sortBy[0].key) || DEFAULT_SORT_HEADER}
|
|
defaultSortDirection={
|
|
(sortBy[0] && sortBy[0].direction) || DEFAULT_SORT_DIRECTION
|
|
}
|
|
pageSize={100}
|
|
actionButtonText={"Edit columns"}
|
|
actionButtonIcon={EditColumnsIcon}
|
|
actionButtonVariant={"text-icon"}
|
|
additionalQueries={JSON.stringify(selectedFilters)}
|
|
inputPlaceHolder={HOSTS_SEARCH_BOX_PLACEHOLDER}
|
|
primarySelectActionButtonText={"Delete"}
|
|
primarySelectActionButtonIcon={"delete"}
|
|
primarySelectActionButtonVariant={"text-icon"}
|
|
secondarySelectActions={secondarySelectActions}
|
|
resultsTitle={"hosts"}
|
|
showMarkAllPages
|
|
isAllPagesSelected={isAllMatchingHostsSelected}
|
|
searchable
|
|
renderCount={renderHostCount}
|
|
searchToolTipText={HOSTS_SEARCH_BOX_TOOLTIP}
|
|
emptyComponent={EmptyHosts}
|
|
customControl={renderCustomControls}
|
|
onActionButtonClick={toggleEditColumnsModal}
|
|
onPrimarySelectActionClick={onDeleteHostsClick}
|
|
onQueryChange={onTableQueryChange}
|
|
toggleAllPagesSelected={toggleAllMatchingHosts}
|
|
resetPageIndex={resetPageIndex}
|
|
disableNextPage={isLastPage}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const renderNoEnrollSecretBanner = () => {
|
|
const noTeamEnrollSecrets =
|
|
currentTeam &&
|
|
!!currentTeam?.id &&
|
|
!isTeamSecretsLoading &&
|
|
!teamSecrets?.length;
|
|
const noGlobalEnrollSecrets =
|
|
(!isPremiumTier ||
|
|
(isPremiumTier && !currentTeam?.id && !isLoadingTeams)) &&
|
|
!isGlobalSecretsLoading &&
|
|
!globalSecrets?.length;
|
|
|
|
return (
|
|
((canEnrollHosts && noTeamEnrollSecrets) ||
|
|
(canEnrollGlobalHosts && noGlobalEnrollSecrets)) &&
|
|
showNoEnrollSecretBanner && (
|
|
<div className={`${baseClass}__no-enroll-secret-banner`}>
|
|
<div>
|
|
<span>
|
|
You have no enroll secrets. Manage enroll secrets to enroll hosts
|
|
to <b>{currentTeam?.id ? currentTeam.name : "Fleet"}</b>.
|
|
</span>
|
|
</div>
|
|
<div className={`dismiss-banner-button`}>
|
|
<Button
|
|
variant="unstyled"
|
|
onClick={() =>
|
|
setShowNoEnrollSecretBanner(!showNoEnrollSecretBanner)
|
|
}
|
|
>
|
|
<img alt="Dismiss no enroll secret banner" src={CloseIconBlack} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
);
|
|
};
|
|
|
|
if (!teamSync) {
|
|
return <Spinner />;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<MainContent>
|
|
<div className={`${baseClass}`}>
|
|
<div className="header-wrap">
|
|
{renderHeader()}
|
|
<div className={`${baseClass} button-wrap`}>
|
|
{!isSandboxMode &&
|
|
canEnrollHosts &&
|
|
!hasHostErrors &&
|
|
!hasHostCountErrors && (
|
|
<Button
|
|
onClick={() => setShowEnrollSecretModal(true)}
|
|
className={`${baseClass}__enroll-hosts button`}
|
|
variant="inverse"
|
|
>
|
|
<span>Manage enroll secret</span>
|
|
</Button>
|
|
)}
|
|
{canEnrollHosts &&
|
|
!hasHostErrors &&
|
|
!hasHostCountErrors &&
|
|
!(
|
|
!status &&
|
|
filteredHostCount === 0 &&
|
|
searchQuery === "" &&
|
|
teamSync &&
|
|
!labelID
|
|
) && (
|
|
<Button
|
|
onClick={toggleAddHostsModal}
|
|
className={`${baseClass}__add-hosts`}
|
|
variant="brand"
|
|
>
|
|
<span>Add hosts</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{renderActiveFilterBlock()}
|
|
{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;
|