Add mobile device management and device-user mapping information to host details to UI (#3499)

* Add mdm, munki and device-user mapping to UI
This commit is contained in:
gillespi314 2021-12-27 17:57:15 -06:00 committed by GitHub
parent e776c2ea36
commit 6d2d28d5a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 220 additions and 143 deletions

View file

@ -0,0 +1,2 @@
* Surface mobile device management and munki information from macadmins osquery extension on host details page
* Surface google chrome profile information from macadmins osquery extension on host details page

View file

@ -64,18 +64,27 @@ export default PropTypes.shape({
export interface IDeviceUser {
email: string;
source: string;
}
export interface IDeviceMappingResponse {
device_mapping: IDeviceUser[];
}
export interface IMunkiData {
version: string;
last_run_time: string;
packages_intalled_count: number;
errors_count: number;
}
export interface IMDMData {
health: string;
enrollment_url: string;
enrollment_status: string;
server_url: string;
}
export interface IMacadminsResponse {
macadmins: null | {
munki: null | IMunkiData;
mobile_device_management: IMDMData;
};
}
export interface IPackStats {

View file

@ -48,7 +48,7 @@ const WelcomeHost = (): JSX.Element => {
refetch: fullyReloadHost,
} = useQuery<IHostResponse, Error, IHost>(
["host"],
() => hostAPI.load(HOST_ID),
() => hostAPI.loadHostDetails(HOST_ID),
{
select: (data: IHostResponse) => data.host,
onSuccess: (returnedHost) => {

View file

@ -16,7 +16,12 @@ import queryAPI from "services/entities/queries";
import teamAPI from "services/entities/teams";
import { AppContext } from "context/app";
import { PolicyContext } from "context/policy";
import { IHost, IPackStats } from "interfaces/host";
import {
IHost,
IDeviceMappingResponse,
IMacadminsResponse,
IPackStats,
} from "interfaces/host";
import { IQueryStats } from "interfaces/query_stats";
import { ISoftware } from "interfaces/software";
import { IHostPolicy } from "interfaces/policy";
@ -46,7 +51,6 @@ import {
AccordionItemPanel,
} from "react-accessible-accordion";
import {
humanTimeAgo,
humanHostUptime,
humanHostLastSeen,
humanHostEnrolled,
@ -157,10 +161,7 @@ const HostDetailsPage = ({
);
const [refetchStartTime, setRefetchStartTime] = useState<number | null>(null);
const [
showRefetchLoadingSpinner,
setShowRefetchLoadingSpinner,
] = useState<boolean>(false);
const [showRefetchSpinner, setShowRefetchSpinner] = useState<boolean>(false);
const [packsState, setPacksState] = useState<IPackStats[]>();
const [scheduleState, setScheduleState] = useState<IQueryStats[]>();
const [softwareState, setSoftwareState] = useState<ISoftware[]>([]);
@ -181,60 +182,62 @@ const HostDetailsPage = ({
select: (data: IFleetQueriesResponse) => data.queries,
});
const { data: teams, error: teamsError } = useQuery<
ITeamsResponse,
Error,
ITeam[]
>("teams", () => teamAPI.loadAll(), {
enabled: !!hostIdFromURL && !!isPremiumTier,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
select: (data: ITeamsResponse) => data.teams,
});
const { data: teams } = useQuery<ITeamsResponse, Error, ITeam[]>(
"teams",
() => teamAPI.loadAll(),
{
enabled: !!hostIdFromURL && !!isPremiumTier,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
select: (data: ITeamsResponse) => data.teams,
}
);
const { data: deviceMapping, refetch: refetchDeviceMapping } = useQuery(
["deviceMapping", hostIdFromURL],
() => hostAPI.loadHostDetailsExtension(hostIdFromURL, "device_mapping"),
{
enabled: !!hostIdFromURL,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
select: (data: IDeviceMappingResponse) => data.device_mapping,
}
);
const { data: macadmins, refetch: refetchMacadmins } = useQuery(
["macadmins", hostIdFromURL],
() => hostAPI.loadHostDetailsExtension(hostIdFromURL, "macadmins"),
{
enabled: !!hostIdFromURL,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
select: (data: IMacadminsResponse) => data.macadmins,
}
);
const refetchExtensions = () => {
deviceMapping !== null && refetchDeviceMapping();
macadmins !== null && refetchMacadmins();
};
const {
isLoading: isLoadingHost,
data: host,
refetch: fullyReloadHost,
refetch: refetchHostDetails,
} = useQuery<IHostResponse, Error, IHost>(
["host", hostIdFromURL],
() => hostAPI.load(hostIdFromURL),
() => hostAPI.loadHostDetails(hostIdFromURL),
{
enabled: !!hostIdFromURL,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
select: (data: IHostResponse) => data.host,
// The onSuccess method below will run each time react-query successfully fetches data from
// the hosts API through this useQuery hook.
// This includes the initial page load as well as whenever we call react-query's refetch method,
// which above we renamed to fullyReloadHost. For example, we use fullyReloadHost with the refetch
// button and also after actions like team transfers.
onSuccess: (returnedHost) => {
setSoftwareState(returnedHost.software);
setUsersState(returnedHost.users);
if (returnedHost.pack_stats) {
const packStatsByType = returnedHost.pack_stats.reduce(
(
dictionary: { packs: IPackStats[]; schedule: IQueryStats[] },
pack: IPackStats
) => {
if (pack.type === "pack") {
dictionary.packs.push(pack);
} else {
dictionary.schedule.push(...pack.query_stats);
}
return dictionary;
},
{ packs: [], schedule: [] }
);
setPacksState(packStatsByType.packs);
setScheduleState(packStatsByType.schedule);
}
setShowRefetchLoadingSpinner(returnedHost.refetch_requested);
setShowRefetchSpinner(returnedHost.refetch_requested);
if (returnedHost.refetch_requested) {
// If the API reports that a Fleet refetch request is pending, we want to check back for fresh
// host details. Here we set a one second timeout and poll the API again using
@ -248,17 +251,19 @@ const HostDetailsPage = ({
if (returnedHost.status === "online") {
setRefetchStartTime(Date.now());
setTimeout(() => {
fullyReloadHost();
refetchHostDetails();
refetchExtensions();
}, 1000);
} else {
setShowRefetchLoadingSpinner(false);
setShowRefetchSpinner(false);
}
} else {
const totalElapsedTime = Date.now() - refetchStartTime;
if (totalElapsedTime < 60000) {
if (returnedHost.status === "online") {
setTimeout(() => {
fullyReloadHost();
refetchHostDetails();
refetchExtensions();
}, 1000);
} else {
dispatch(
@ -267,7 +272,7 @@ const HostDetailsPage = ({
`This host is offline. Please try refetching host vitals later.`
)
);
setShowRefetchLoadingSpinner(false);
setShowRefetchSpinner(false);
}
} else {
dispatch(
@ -276,9 +281,33 @@ const HostDetailsPage = ({
`We're having trouble fetching fresh vitals for this host. Please try again later.`
)
);
setShowRefetchLoadingSpinner(false);
setShowRefetchSpinner(false);
}
}
return; // exit early because refectch is pending so we can avoid unecessary steps below
}
setSoftwareState(returnedHost.software);
setUsersState(returnedHost.users);
if (returnedHost.pack_stats) {
const packStatsByType = returnedHost.pack_stats.reduce(
(
dictionary: {
packs: IPackStats[];
schedule: IQueryStats[];
},
pack: IPackStats
) => {
if (pack.type === "pack") {
dictionary.packs.push(pack);
} else {
dictionary.schedule.push(...pack.query_stats);
}
return dictionary;
},
{ packs: [], schedule: [] }
);
setPacksState(packStatsByType.packs);
setScheduleState(packStatsByType.schedule);
}
},
onError: (error) => {
@ -439,16 +468,19 @@ const HostDetailsPage = ({
// Once the user clicks to refetch, the refetch loading spinner should continue spinning
// unless there is an error. The spinner state is also controlled in the fullyReloadHost
// method.
setShowRefetchLoadingSpinner(true);
setShowRefetchSpinner(true);
try {
await hostAPI.refetch(host).then(() => {
setRefetchStartTime(Date.now());
setTimeout(() => fullyReloadHost(), 1000);
setTimeout(() => {
refetchHostDetails();
refetchExtensions();
}, 1000);
});
} catch (error) {
console.log(error);
dispatch(renderFlash("error", `Host "${host.hostname}" refetch error`));
setShowRefetchLoadingSpinner(false);
setShowRefetchSpinner(false);
}
}
};
@ -484,7 +516,7 @@ const HostDetailsPage = ({
: `Host successfully transferred to ${team.name}.`;
dispatch(renderFlash("success", successMessage));
fullyReloadHost();
refetchHostDetails(); // Note: it is not necessary to `refetchExtensions` here because only team has changed
setShowTransferHostModal(false);
} catch (error) {
console.log(error);
@ -970,19 +1002,19 @@ const HostDetailsPage = ({
className="refetch"
data-tip
data-for="refetch-tooltip"
data-tip-disable={isOnline || showRefetchLoadingSpinner}
data-tip-disable={isOnline || showRefetchSpinner}
>
<Button
className={`
button
button--unstyled
${!isOnline ? "refetch-offline" : ""}
${showRefetchLoadingSpinner ? "refetch-spinner" : "refetch-btn"}
${showRefetchSpinner ? "refetch-spinner" : "refetch-btn"}
`}
disabled={!isOnline}
onClick={onRefetchHost}
>
{showRefetchLoadingSpinner
{showRefetchSpinner
? "Fetching fresh vitals...this may take a moment"
: "Refetch"}
</Button>
@ -1047,18 +1079,47 @@ const HostDetailsPage = ({
);
const renderDeviceUser = () => {
if (host?.device_users && host?.device_users.length > 0) {
const numUsers = deviceMapping?.length;
if (numUsers) {
return (
// max width is added here because this is the only div that needs it
<div
className="info-flex__item info-flex__item--title"
style={{ maxWidth: 216 }}
>
<span className="info-flex__header">Device user</span>
<span className="info-flex__data">{host.device_users[0].email}</span>
<div className="info-grid__block">
<span className="info-grid__header">Device user</span>
<span className="info-grid__data">
{numUsers === 1 ? (
deviceMapping[0].email || "---"
) : (
<span className={`${baseClass}__device-mapping`}>
<span
className="device-user"
data-tip
data-for="device-user-tooltip"
>
{`${numUsers} users`}
</span>
<ReactTooltip
place="top"
type="dark"
effect="solid"
id="device-user-tooltip"
backgroundColor="#3e4771"
>
<div
className={`${baseClass}__tooltip-text device-user-tooltip`}
>
{deviceMapping.map((user, i, arr) => (
<span key={user.email}>{`${user.email}${
i < arr.length - 1 ? ", " : ""
}`}</span>
))}
</div>
</ReactTooltip>
</span>
)}
</span>
</div>
);
}
return null;
};
const renderDiskSpace = () => {
@ -1088,50 +1149,40 @@ const HostDetailsPage = ({
return <span className="info-flex__data">No data available</span>;
};
const renderMunkiData = () => {
if (host?.munki) {
return (
<>
<div className="info-grid__block">
<span className="info-grid__header">Munki last run</span>
<span className="info-grid__data">
{humanTimeAgo(host.munki.last_run_time)} days ago
</span>
</div>
<div className="info-grid__block">
<span className="info-grid__header">Munki packages installed</span>
<span className="info-grid__data">
{host.munki.packages_intalled_count}
</span>
</div>
<div className="info-grid__block">
<span className="info-grid__header">Munki errors</span>
<span className="info-grid__data">{host.munki.errors_count}</span>
</div>
<div className="info-grid__block">
<span className="info-grid__header">Munki version</span>
<span className="info-grid__data">{host.munki.version}</span>
</div>
</>
);
const renderMdmData = () => {
if (!macadmins) {
return null;
}
const { mobile_device_management: mdm } = macadmins;
return mdm.enrollment_status !== "Unenrolled" ? (
<>
<div className="info-grid__block">
<span className="info-grid__header">MDM enrollment</span>
<span className="info-grid__data">
{mdm.enrollment_status || "---"}
</span>
</div>
<div className="info-grid__block">
<span className="info-grid__header">MDM server URL</span>
<span className="info-grid__data">{mdm.server_url || "---"}</span>
</div>
</>
) : null;
};
const renderMDMData = () => {
if (host?.mdm) {
return (
<>
<div className="info-grid__block">
<span className="info-grid__header">MDM health</span>
<span className="info-grid__data">{host.mdm?.health}</span>
</div>
<div className="info-grid__block">
<span className="info-grid__header">MDM enrollment URL</span>
<span className="info-grid__data">{host.mdm.enrollment_url}</span>
</div>
</>
);
const renderMunkiData = () => {
if (!macadmins) {
return null;
}
const { munki } = macadmins;
return munki ? (
<>
<div className="info-grid__block">
<span className="info-grid__header">Munki version</span>
<span className="info-grid__data">{munki.version || "---"}</span>
</div>
</>
) : null;
};
if (isLoadingHost) {
@ -1173,7 +1224,6 @@ const HostDetailsPage = ({
</div>
{titleData.issues?.total_issues_count > 0 && renderIssues()}
{isPremiumTier && renderHostTeam()}
{renderDeviceUser()}
<div className="info-flex__item info-flex__item--title">
<span className="info-flex__header">Disk Space</span>
{renderDiskSpace()}
@ -1267,7 +1317,8 @@ const HostDetailsPage = ({
</span>
</div>
{renderMunkiData()}
{renderMDMData()}
{renderMdmData()}
{renderDeviceUser()}
</div>
</div>
<div className="col-2">

View file

@ -386,6 +386,14 @@
text-align: center;
}
&__device-mapping {
.device-user-tooltip {
flex-direction: column;
justify-content: start;
text-align: left;
}
}
&__wrapper {
border: solid 1px $ui-fleet-blue-15;
border-radius: 6px;

View file

@ -13,7 +13,7 @@ import teamsAPI from "services/entities/teams";
import globalPoliciesAPI from "services/entities/global_policies";
import teamPoliciesAPI from "services/entities/team_policies";
import hostsAPI, {
IHostLoadOptions,
ILoadHostsOptions,
ISortOption,
} from "services/entities/hosts";
import hostCountAPI, {
@ -131,9 +131,7 @@ const ManageHostsPage = ({
config,
isGlobalAdmin,
isGlobalMaintainer,
isAnyTeamMaintainer,
isTeamMaintainer,
isAnyTeamAdmin,
isTeamAdmin,
isOnGlobalTeam,
isOnlyObserver,
@ -270,7 +268,6 @@ const ManageHostsPage = ({
const {
isLoading: isTeamSecretsLoading,
data: teamSecrets,
error: teamSecretsError,
refetch: refetchTeamSecrets,
} = useQuery<IEnrollSecretsResponse, Error, IEnrollSecret[]>(
["team secrets", currentTeam],
@ -385,7 +382,7 @@ const ManageHostsPage = ({
return selectedFilters.find((f) => !f.includes(LABEL_SLUG_PREFIX));
};
const retrieveHosts = async (options: IHostLoadOptions = {}) => {
const retrieveHosts = async (options: ILoadHostsOptions = {}) => {
setIsHostsLoading(true);
options = {
@ -403,7 +400,7 @@ const ManageHostsPage = ({
}
try {
const { hosts: returnedHosts, software } = await hostsAPI.loadAll(
const { hosts: returnedHosts, software } = await hostsAPI.loadHosts(
options
);
setHosts(returnedHosts);
@ -444,7 +441,7 @@ const ManageHostsPage = ({
}
};
const refetchHosts = (options: IHostLoadOptions) => {
const refetchHosts = (options: ILoadHostsOptions) => {
retrieveHosts(options);
if (options.sortBy) {
delete options.sortBy;
@ -473,7 +470,7 @@ const ManageHostsPage = ({
setSelectedLabel(selected);
// get the hosts
const options: IHostLoadOptions = {
const options: ILoadHostsOptions = {
selectedLabels: selectedFilters,
globalFilter: searchQuery,
sortBy,
@ -510,7 +507,7 @@ const ManageHostsPage = ({
setSelectedLabel(selected);
// get the hosts
const options: IHostLoadOptions = {
const options: ILoadHostsOptions = {
selectedLabels: selectedFilters,
globalFilter: searchQuery,
sortBy,

View file

@ -318,8 +318,6 @@ const ManagePolicyPage = (managePoliciesPageProps: {
const showDefaultDescription =
isFreeTier || (isPremiumTier && !selectedTeamId && selectedTeamId !== null);
// If there aren't any policies of if there are loading errors, we don't show the update interval info banner.
// We also want to check selectTeamId for the null case so that we don't render the element prematurely.
const showInfoBanner =
(selectedTeamId && !isTeamPoliciesError && !!teamPolicies?.length) ||
(!selectedTeamId &&
@ -327,7 +325,6 @@ const ManagePolicyPage = (managePoliciesPageProps: {
!isGlobalPoliciesError &&
!!globalPolicies?.length);
// If there aren't any policies of if there are loading errors, we don't show the inherited policies button.
const showInheritedPoliciesButton =
!!selectedTeamId && !!globalPolicies?.length && !isGlobalPoliciesError;

View file

@ -126,7 +126,8 @@ const PolicyPage = ({
// to the selected targets automatically
useQuery<IHostResponse, Error, IHost>(
"hostFromURL",
() => hostAPI.load(parseInt(URLQuerySearch.host_ids as string, 10)),
() =>
hostAPI.loadHostDetails(parseInt(URLQuerySearch.host_ids as string, 10)),
{
enabled: !!URLQuerySearch.host_ids,
select: (data: IHostResponse) => data.host,

View file

@ -95,7 +95,8 @@ const QueryPage = ({
// to the selected targets automatically
useQuery<IHostResponse, Error, IHost>(
"hostFromURL",
() => hostAPI.load(parseInt(URLQuerySearch.host_ids as string, 10)),
() =>
hostAPI.loadHostDetails(parseInt(URLQuerySearch.host_ids as string, 10)),
{
enabled: !!URLQuerySearch.host_ids && !queryParamHostsAdded,
select: (data: IHostResponse) => data.host,

View file

@ -8,7 +8,7 @@ export interface ISortOption {
direction: string;
}
export interface IHostLoadOptions {
export interface ILoadHostsOptions {
page?: number;
perPage?: number;
selectedLabels?: string[];
@ -20,6 +20,8 @@ export interface IHostLoadOptions {
softwareId?: number;
}
export type ILoadHostDetailsExtension = "device_mapping" | "macadmins";
export default {
destroy: (host: IHost) => {
const { HOSTS } = endpoints;
@ -48,19 +50,7 @@ export default {
},
});
},
refetch: (host: IHost) => {
const { HOSTS } = endpoints;
const path = `${HOSTS}/${host.id}/refetch`;
return sendRequest("POST", path);
},
load: (hostID: number) => {
const { HOSTS } = endpoints;
const path = `${HOSTS}/${hostID}`;
return sendRequest("GET", path);
},
loadAll: (options: IHostLoadOptions | undefined) => {
loadHosts: (options: ILoadHostsOptions | undefined) => {
const { HOSTS, LABEL_HOSTS } = endpoints;
const page = options?.page || 0;
const perPage = options?.perPage || 100;
@ -131,6 +121,27 @@ export default {
return sendRequest("GET", path);
},
loadHostDetails: (hostID: number) => {
const { HOSTS } = endpoints;
const path = `${HOSTS}/${hostID}`;
return sendRequest("GET", path);
},
loadHostDetailsExtension: (
hostID: number,
extension: ILoadHostDetailsExtension
) => {
const { HOSTS } = endpoints;
const path = `${HOSTS}/${hostID}/${extension}`;
return sendRequest("GET", path);
},
refetch: (host: IHost) => {
const { HOSTS } = endpoints;
const path = `${HOSTS}/${host.id}/refetch`;
return sendRequest("POST", path);
},
search: (searchText: string) => {
const { HOSTS } = endpoints;
const path = `${HOSTS}?query=${searchText}`;