diff --git a/changes/issues-3317-3350-mdm-munki-device-mapping-ui b/changes/issues-3317-3350-mdm-munki-device-mapping-ui new file mode 100644 index 0000000000..a96f364ba1 --- /dev/null +++ b/changes/issues-3317-3350-mdm-munki-device-mapping-ui @@ -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 \ No newline at end of file diff --git a/frontend/interfaces/host.ts b/frontend/interfaces/host.ts index 120e1b007b..dbd84d8bf4 100644 --- a/frontend/interfaces/host.ts +++ b/frontend/interfaces/host.ts @@ -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 { diff --git a/frontend/pages/Homepage/cards/WelcomeHost/WelcomeHost.tsx b/frontend/pages/Homepage/cards/WelcomeHost/WelcomeHost.tsx index 4b1f1a6498..9b7d25f9cf 100644 --- a/frontend/pages/Homepage/cards/WelcomeHost/WelcomeHost.tsx +++ b/frontend/pages/Homepage/cards/WelcomeHost/WelcomeHost.tsx @@ -48,7 +48,7 @@ const WelcomeHost = (): JSX.Element => { refetch: fullyReloadHost, } = useQuery( ["host"], - () => hostAPI.load(HOST_ID), + () => hostAPI.loadHostDetails(HOST_ID), { select: (data: IHostResponse) => data.host, onSuccess: (returnedHost) => { diff --git a/frontend/pages/hosts/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/HostDetailsPage/HostDetailsPage.tsx index 0cc26fe76b..d1aa8ebb44 100644 --- a/frontend/pages/hosts/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/HostDetailsPage/HostDetailsPage.tsx @@ -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(null); - const [ - showRefetchLoadingSpinner, - setShowRefetchLoadingSpinner, - ] = useState(false); + const [showRefetchSpinner, setShowRefetchSpinner] = useState(false); const [packsState, setPacksState] = useState(); const [scheduleState, setScheduleState] = useState(); const [softwareState, setSoftwareState] = useState([]); @@ -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( + "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( ["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} > @@ -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 -
- Device user - {host.device_users[0].email} +
+ Device user + + {numUsers === 1 ? ( + deviceMapping[0].email || "---" + ) : ( + + + {`${numUsers} users`} + + +
+ {deviceMapping.map((user, i, arr) => ( + {`${user.email}${ + i < arr.length - 1 ? ", " : "" + }`} + ))} +
+
+
+ )} +
); } + return null; }; const renderDiskSpace = () => { @@ -1088,50 +1149,40 @@ const HostDetailsPage = ({ return No data available; }; - const renderMunkiData = () => { - if (host?.munki) { - return ( - <> -
- Munki last run - - {humanTimeAgo(host.munki.last_run_time)} days ago - -
-
- Munki packages installed - - {host.munki.packages_intalled_count} - -
-
- Munki errors - {host.munki.errors_count} -
-
- Munki version - {host.munki.version} -
- - ); + const renderMdmData = () => { + if (!macadmins) { + return null; } + const { mobile_device_management: mdm } = macadmins; + return mdm.enrollment_status !== "Unenrolled" ? ( + <> +
+ MDM enrollment + + {mdm.enrollment_status || "---"} + +
+
+ MDM server URL + {mdm.server_url || "---"} +
+ + ) : null; }; - const renderMDMData = () => { - if (host?.mdm) { - return ( - <> -
- MDM health - {host.mdm?.health} -
-
- MDM enrollment URL - {host.mdm.enrollment_url} -
- - ); + const renderMunkiData = () => { + if (!macadmins) { + return null; } + const { munki } = macadmins; + return munki ? ( + <> +
+ Munki version + {munki.version || "---"} +
+ + ) : null; }; if (isLoadingHost) { @@ -1173,7 +1224,6 @@ const HostDetailsPage = ({
{titleData.issues?.total_issues_count > 0 && renderIssues()} {isPremiumTier && renderHostTeam()} - {renderDeviceUser()}
Disk Space {renderDiskSpace()} @@ -1267,7 +1317,8 @@ const HostDetailsPage = ({
{renderMunkiData()} - {renderMDMData()} + {renderMdmData()} + {renderDeviceUser()}
diff --git a/frontend/pages/hosts/HostDetailsPage/_styles.scss b/frontend/pages/hosts/HostDetailsPage/_styles.scss index 22499bda85..46edc285c1 100644 --- a/frontend/pages/hosts/HostDetailsPage/_styles.scss +++ b/frontend/pages/hosts/HostDetailsPage/_styles.scss @@ -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; diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index 510154ecef..edfe7d4cb5 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -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( ["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, diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx index 4881924fe0..da529add36 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx @@ -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; diff --git a/frontend/pages/policies/PolicyPage/PolicyPage.tsx b/frontend/pages/policies/PolicyPage/PolicyPage.tsx index c8b632e859..610daceff6 100644 --- a/frontend/pages/policies/PolicyPage/PolicyPage.tsx +++ b/frontend/pages/policies/PolicyPage/PolicyPage.tsx @@ -126,7 +126,8 @@ const PolicyPage = ({ // to the selected targets automatically useQuery( "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, diff --git a/frontend/pages/queries/QueryPage/QueryPage.tsx b/frontend/pages/queries/QueryPage/QueryPage.tsx index 49399448f2..4648be270d 100644 --- a/frontend/pages/queries/QueryPage/QueryPage.tsx +++ b/frontend/pages/queries/QueryPage/QueryPage.tsx @@ -95,7 +95,8 @@ const QueryPage = ({ // to the selected targets automatically useQuery( "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, diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index cc6919a218..446a5c261f 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -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}`;