import React, { useContext, useState, useCallback, useEffect } from "react"; import { useDispatch } from "react-redux"; import { Link } from "react-router"; import { Params } from "react-router/lib/Router"; import { useQuery } from "react-query"; import { Tab, Tabs, TabList, TabPanel } from "react-tabs"; import classnames from "classnames"; import { isEmpty, pick, reduce } from "lodash"; import PATHS from "router/paths"; import hostAPI from "services/entities/hosts"; import queryAPI from "services/entities/queries"; import teamAPI from "services/entities/teams"; import { AppContext } from "context/app"; import { IHost, IPackStats } from "interfaces/host"; import { IQueryStats } from "interfaces/query_stats"; import { ISoftware } from "interfaces/software"; import { IHostPolicy } from "interfaces/host_policy"; import { ILabel } from "interfaces/label"; import { ITeam } from "interfaces/team"; import { IQuery } from "interfaces/query"; import { IUser } from "interfaces/user"; // @ts-ignore import { renderFlash } from "redux/nodes/notifications/actions"; import permissionUtils from "utilities/permissions"; import ReactTooltip from "react-tooltip"; import Spinner from "components/Spinner"; import Button from "components/buttons/Button"; import Modal from "components/Modal"; import SoftwareVulnerabilities from "pages/hosts/HostDetailsPage/SoftwareVulnCount"; import TableContainer from "components/TableContainer"; import TabsWrapper from "components/TabsWrapper"; import InfoBanner from "components/InfoBanner"; import { Accordion, AccordionItem, AccordionItemHeading, AccordionItemButton, AccordionItemPanel, } from "react-accessible-accordion"; import { humanTimeAgo, humanHostUptime, humanHostLastSeen, humanHostEnrolled, humanHostMemory, humanHostDetailUpdated, secondsToHms, } from "fleet/helpers"; // @ts-ignore import SelectQueryModal from "./SelectQueryModal"; import TransferHostModal from "./TransferHostModal"; import PolicyDetailsModal from "./HostPoliciesTable/PolicyDetailsModal"; import { generatePolicyTableHeaders, generatePolicyDataSet, } from "./HostPoliciesTable/HostPoliciesTableConfig"; import generateSoftwareTableHeaders from "./SoftwareTable/SoftwareTableConfig"; import generateUsersTableHeaders from "./UsersTable/UsersTableConfig"; import { generatePackTableHeaders, generatePackDataSet, } from "./PackTable/PackTableConfig"; import EmptySoftware from "./EmptySoftware"; import EmptyUsers from "./EmptyUsers"; import PolicyFailingCount from "./HostPoliciesTable/PolicyFailingCount"; import { isValidPolicyResponse } from "../ManageHostsPage/helpers"; import BackChevron from "../../../../assets/images/icon-chevron-down-9x6@2x.png"; import DeleteIcon from "../../../../assets/images/icon-action-delete-14x14@2x.png"; import TransferIcon from "../../../../assets/images/icon-action-transfer-16x16@2x.png"; import QueryIcon from "../../../../assets/images/icon-action-query-16x16@2x.png"; import IssueIcon from "../../../../assets/images/icon-issue-fleet-black-50-16x16@2x.png"; const baseClass = "host-details"; interface IHostDetailsProps { router: any; params: Params; } interface IFleetQueriesResponse { queries: IQuery[]; } interface ITeamsResponse { teams: ITeam[]; } interface IHostResponse { host: IHost; } const HostDetailsPage = ({ router, params: { host_id }, }: IHostDetailsProps): JSX.Element => { const hostIdFromURL = parseInt(host_id, 10); const dispatch = useDispatch(); const { isGlobalAdmin, isPremiumTier, isOnlyObserver, isGlobalMaintainer, currentUser, } = useContext(AppContext); const canTransferTeam = isPremiumTier && (isGlobalAdmin || isGlobalMaintainer); const canDeleteHost = (user: IUser, host: IHost) => { if ( isGlobalAdmin || isGlobalMaintainer || permissionUtils.isTeamAdmin(user, host.team_id) || permissionUtils.isTeamMaintainer(user, host.team_id) ) { return true; } return false; }; const [showDeleteHostModal, setShowDeleteHostModal] = useState( false ); const [showTransferHostModal, setShowTransferHostModal] = useState( false ); const [showQueryHostModal, setShowQueryHostModal] = useState(false); const [showPolicyDetailsModal, setPolicyDetailsModal] = useState( false ); const [selectedPolicy, setSelectedPolicy] = useState( null ); const togglePolicyDetailsModal = useCallback( (policy: IHostPolicy) => { setPolicyDetailsModal(!showPolicyDetailsModal); setSelectedPolicy(policy); }, [showPolicyDetailsModal, setPolicyDetailsModal, setSelectedPolicy] ); const onCancelPolicyDetailsModal = useCallback(() => { setPolicyDetailsModal(!showPolicyDetailsModal); setSelectedPolicy(null); }, [showPolicyDetailsModal, setPolicyDetailsModal, setSelectedPolicy]); const [refetchStartTime, setRefetchStartTime] = useState(null); const [ showRefetchLoadingSpinner, setShowRefetchLoadingSpinner, ] = useState(false); const [packsState, setPacksState] = useState(); const [scheduleState, setScheduleState] = useState(); const [softwareState, setSoftwareState] = useState([]); const [softwareSearchString, setSoftwareSearchString] = useState(""); const [usersState, setUsersState] = useState<{ username: string }[]>([]); const [usersSearchString, setUsersSearchString] = useState(""); const { data: fleetQueries, error: fleetQueriesError } = useQuery< IFleetQueriesResponse, Error, IQuery[] >("fleet queries", () => queryAPI.loadAll(), { enabled: !!hostIdFromURL, refetchOnMount: false, refetchOnReconnect: false, refetchOnWindowFocus: false, 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 { isLoading: isLoadingHost, data: host, refetch: fullyReloadHost, } = useQuery( ["host", hostIdFromURL], () => hostAPI.load(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); 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 // fullyReloadHost. We will repeat this process with each onSuccess cycle for a total of // 60 seconds or until the API reports that the Fleet refetch request has been resolved // or that the host has gone offline. if (!refetchStartTime) { // If our 60 second timer wasn't already started (e.g., if a refetch was pending when // the first page loads), we start it now if the host is online. If the host is offline, // we skip the refetch on page load. if (returnedHost.status === "online") { setRefetchStartTime(Date.now()); setTimeout(() => { fullyReloadHost(); }, 1000); } else { setShowRefetchLoadingSpinner(false); } } else { const totalElapsedTime = Date.now() - refetchStartTime; if (totalElapsedTime < 60000) { if (returnedHost.status === "online") { setTimeout(() => { fullyReloadHost(); }, 1000); } else { dispatch( renderFlash( "error", `This host is offline. Please try refetching host vitals later.` ) ); setShowRefetchLoadingSpinner(false); } } else { dispatch( renderFlash( "error", `We're having trouble fetching fresh vitals for this host. Please try again later.` ) ); setShowRefetchLoadingSpinner(false); } } } }, onError: (error) => { console.log(error); dispatch( renderFlash("error", `Unable to load host. Please try again.`) ); }, } ); useEffect(() => { setUsersState(() => { return ( host?.users.filter((user) => { return user.username .toLowerCase() .includes(usersSearchString.toLowerCase()); }) || [] ); }); }, [usersSearchString]); useEffect(() => { setSoftwareState(() => { return ( host?.software.filter((softwareItem) => { return softwareItem.name .toLowerCase() .includes(softwareSearchString.toLowerCase()); }) || [] ); }); }, [softwareSearchString]); // returns a mixture of props from host const normalizeEmptyValues = (hostData: any): { [key: string]: any } => { return reduce( hostData, (result, value, key) => { if ((Number.isFinite(value) && value !== 0) || !isEmpty(value)) { Object.assign(result, { [key]: value }); } else { Object.assign(result, { [key]: "---" }); } return result; }, {} ); }; const wrapFleetHelper = ( helperFn: (value: any) => string, value: string ): any => { return value === "---" ? value : helperFn(value); }; const titleData = normalizeEmptyValues( pick(host, [ "status", "issues", "memory", "cpu_type", "os_version", "osquery_version", "enroll_secret_name", "detail_updated_at", "percent_disk_space_available", "gigs_disk_space_available", ]) ); const aboutData = normalizeEmptyValues( pick(host, [ "seen_time", "uptime", "last_enrolled_at", "hardware_model", "hardware_serial", "primary_ip", ]) ); const osqueryData = normalizeEmptyValues( pick(host, [ "config_tls_refresh", "logger_tls_period", "distributed_interval", ]) ); const onDestroyHost = async () => { if (host) { try { await hostAPI.destroy(host); dispatch( renderFlash( "success", `Host "${host.hostname}" was successfully deleted.` ) ); router.push(PATHS.MANAGE_HOSTS); } catch (error) { console.log(error); dispatch( renderFlash("error", `Host "${host.hostname}" could not be deleted.`) ); } finally { setShowDeleteHostModal(false); } } }; const onRefetchHost = async () => { if (host) { // 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); try { await hostAPI.refetch(host).then(() => { setRefetchStartTime(Date.now()); setTimeout(() => fullyReloadHost(), 1000); }); } catch (error) { console.log(error); dispatch(renderFlash("error", `Host "${host.hostname}" refetch error`)); setShowRefetchLoadingSpinner(false); } } }; const onLabelClick = (label: ILabel) => { if (label.name === "All Hosts") { return router.push(PATHS.MANAGE_HOSTS); } return router.push(`${PATHS.MANAGE_HOSTS}/labels/${label.id}`); }; const onTransferHostSubmit = async (team: ITeam) => { const teamId = typeof team.id === "number" ? team.id : null; try { await hostAPI.transferToTeam(teamId, [hostIdFromURL]); const successMessage = teamId === null ? `Host successfully removed from teams.` : `Host successfully transferred to ${team.name}.`; dispatch(renderFlash("success", successMessage)); fullyReloadHost(); setShowTransferHostModal(false); } catch (error) { console.log(error); dispatch( renderFlash("error", "Could not transfer host. Please try again.") ); } }; const onSoftwareTableSearchChange = useCallback((queryData: any) => { const { searchQuery } = queryData; setSoftwareSearchString(searchQuery); }, []); const onUsersTableSearchChange = useCallback((queryData: any) => { const { searchQuery } = queryData; setUsersSearchString(searchQuery); }, []); const renderDeleteHostModal = () => ( setShowDeleteHostModal(false)} className={`${baseClass}__modal`} > <>

This action will delete the host {host?.hostname}{" "} from your Fleet instance.

The host will automatically re-enroll when it checks back into Fleet.

To prevent re-enrollment, you can uninstall osquery on the host or revoke the host's enroll secret.

); const renderActionButtons = () => { const isOnline = host?.status === "online"; return (
{canTransferTeam && ( )}
You can’t query
an offline host.
{currentUser && host && canDeleteHost(currentUser, host) && ( )}
); }; const renderLabels = () => { const { labels = [] } = host || {}; const labelItems = labels.map((label) => { return (
  • ); }); return (

    Labels

    {labels.length === 0 ? (

    No labels are associated with this host.

    ) : (
      {labelItems}
    )}
    ); }; const renderPacks = () => { const packs = packsState; const wrapperClassName = `${baseClass}__pack-table`; const tableHeaders = generatePackTableHeaders(); let packsAccordion; if (packs) { packsAccordion = packs.map((pack) => { return ( {pack.pack_name} {pack.query_stats.length === 0 ? (
    There are no schedule queries for this pack.
    ) : ( <> {!!pack.query_stats.length && (
    null} resultsTitle={"queries"} defaultSortHeader={"scheduled_query_name"} defaultSortDirection={"asc"} showMarkAllPages={false} isAllPagesSelected={false} emptyComponent={() => <>} disablePagination disableCount />
    )} )}
    ); }); } return !packs || !packs.length ? null : (

    Packs

    {packsAccordion}
    ); }; const renderSchedule = () => { const schedule = scheduleState; const wrapperClassName = `${baseClass}__pack-table`; const tableHeaders = generatePackTableHeaders(); return (

    Schedule

    {!schedule || !schedule.length ? (
    No queries are scheduled for this host.

    Expecting to see queries? Try selecting “Refetch” to ask this host to report new vitals.

    ) : (
    null} resultsTitle={"queries"} defaultSortHeader={"scheduled_query_name"} defaultSortDirection={"asc"} showMarkAllPages={false} isAllPagesSelected={false} emptyComponent={() => <>} disablePagination disableCount />
    )}
    ); }; const renderPolicies = () => { if (!host?.policies?.length) { return (

    Policies

    No policies are checked for this host.

    Expecting to see policies? Try selecting “Refetch” to ask this host to report new vitals.

    ); } const tableHeaders = generatePolicyTableHeaders(togglePolicyDetailsModal); const noResponses: IHostPolicy[] = host?.policies?.filter( (policy) => !isValidPolicyResponse(policy.response) ) || []; const failingResponses: IHostPolicy[] = host?.policies?.filter((policy) => policy.response === "fail") || []; return (

    Policies

    {host?.policies?.length && ( <> {failingResponses?.length > 0 && ( )} {noResponses?.length > 0 && (

    This host is not updating the response for some policies. Check out the Fleet documentation on  why the response might not be updating .

    )} <>} showMarkAllPages={false} isAllPagesSelected={false} disablePagination disableCount highlightOnHover /> )}
    ); }; const renderUsers = () => { const { users } = host || {}; const tableHeaders = generateUsersTableHeaders(); if (users) { return (

    Users

    {users.length === 0 ? (

    No users were detected on this host.

    ) : ( )}
    ); } }; const renderSoftware = () => { const tableHeaders = generateSoftwareTableHeaders(); return (

    Software

    {host?.software.length === 0 ? (

    No installed software detected on this host.

    Expecting to see software? Try again in a few seconds as the system catches up.

    ) : ( <> {host?.software && ( )} {host?.software && ( )} )}
    ); }; const renderRefetch = () => { const isOnline = host?.status === "online"; return ( <>
    You can’t fetch data from
    an offline host.
    ); }; const renderIssues = () => (
    Issues host issue Failing policies ({host?.issues.failing_policies_count}) {host?.issues.total_issues_count}
    ); const renderHostTeam = () => (
    Team {host?.team_name ? ( `${host?.team_name}` ) : ( No team )}
    ); const renderDeviceUser = () => { if (host?.device_users && host?.device_users.length > 0) { return ( // max width is added here because this is the only div that needs it
    Device user {host.device_users[0].email}
    ); } }; const renderDiskSpace = () => { if ( host && (host.gigs_disk_space_available > 0 || host.percent_disk_space_available > 0) ) { return (
    20 ? "info-flex__disk-space-used" : "info-flex__disk-space-warning" } style={{ width: `${100 - titleData.percent_disk_space_available}%`, }} />
    {titleData.gigs_disk_space_available} GB available ); } 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 (host?.mdm) { return ( <>
    MDM health {host.mdm?.health}
    MDM enrollment URL {host.mdm.enrollment_url}
    ); } }; if (isLoadingHost) { return ; } const statusClassName = classnames("status", `status--${host?.status}`); return (
    back chevron Back to all hosts

    {host?.hostname || "---"}

    {`Last fetched ${humanHostDetailUpdated( titleData.detail_updated_at )}`}  

    {renderRefetch()}
    {renderActionButtons()}
    Status {titleData.status}
    {titleData.issues?.total_issues_count > 0 && renderIssues()} {isPremiumTier && renderHostTeam()} {renderDeviceUser()}
    Disk Space {renderDiskSpace()}
    RAM {wrapFleetHelper(humanHostMemory, titleData.memory)}
    CPU {titleData.cpu_type}
    OS {titleData.os_version}
    Osquery {titleData.osquery_version}
    Details Schedule Policies

    About this host

    Created at {wrapFleetHelper( humanHostEnrolled, aboutData.last_enrolled_at )}
    Updated at {wrapFleetHelper( humanHostLastSeen, titleData.detail_updated_at )}
    Uptime {wrapFleetHelper(humanHostUptime, aboutData.uptime)}
    Hardware model {aboutData.hardware_model}
    Serial number {aboutData.hardware_serial}
    IPv4 {aboutData.primary_ip}
    {renderMunkiData()} {renderMDMData()}

    Agent options

    Config TLS refresh {wrapFleetHelper( secondsToHms, osqueryData.config_tls_refresh )}
    Logger TLS period {wrapFleetHelper( secondsToHms, osqueryData.logger_tls_period )}
    Distributed interval {wrapFleetHelper( secondsToHms, osqueryData.distributed_interval )}
    {renderLabels()}
    {host?.software && renderSoftware()} {renderUsers()}
    {renderSchedule()} {renderPacks()} {renderPolicies()}
    {showDeleteHostModal && renderDeleteHostModal()} {showQueryHostModal && ( setShowQueryHostModal(false)} queries={fleetQueries} dispatch={dispatch} queryErrors={fleetQueriesError} isOnlyObserver={isOnlyObserver} /> )} {!!host && showTransferHostModal && ( setShowTransferHostModal(false)} onSubmit={onTransferHostSubmit} teams={teams || []} isGlobalAdmin={isGlobalAdmin as boolean} /> )} {!!host && showPolicyDetailsModal && ( )}
    ); }; export default HostDetailsPage;