Fleet UI: Device user/Host details page layout changing including split out host header and summary card (#28598)

This commit is contained in:
RachelElysia 2025-04-28 13:00:13 -04:00 committed by GitHub
parent 6b56dc80a9
commit 3b42be5571
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 696 additions and 493 deletions

View file

@ -0,0 +1 @@
* Updated "My device page" layout

View file

@ -73,6 +73,7 @@ import {
generateChromeProfilesValues,
generateOtherEmailsValues,
} from "../cards/User/helpers";
import HostHeader from "../cards/HostHeader/HostHeader";
const baseClass = "device-user";
@ -443,16 +444,11 @@ const DeviceUserPage = ({
diskIsEncrypted={host.disk_encryption_enabled}
diskEncryptionKeyAvailable={host.mdm.encryption_key_available}
/>
<HostSummaryCard
<HostHeader
summaryData={summaryData}
bootstrapPackageData={bootstrapPackageData}
isPremiumTier={isPremiumTier}
toggleOSSettingsModal={toggleOSSettingsModal}
hostSettings={host?.mdm.profiles ?? []}
showRefetchSpinner={showRefetchSpinner}
onRefetchHost={onRefetchHost}
renderActionDropdown={renderActionButtons}
osSettings={host?.mdm.os_settings}
deviceUser
/>
<TabNav className={`${baseClass}__tab-nav`}>
@ -461,14 +457,14 @@ const DeviceUserPage = ({
onSelect={(i) => router.push(tabPaths[i])}
>
<TabList>
<Tab>
<TabText>Details</TabText>
</Tab>
{isPremiumTier && isSoftwareEnabled && hasSelfService && (
<Tab>
<TabText>Self-service</TabText>
</Tab>
)}
<Tab>
<TabText>Details</TabText>
</Tab>
{isSoftwareEnabled && (
<Tab>
<TabText>Software</TabText>
@ -482,7 +478,28 @@ const DeviceUserPage = ({
</Tab>
)}
</TabList>
{isPremiumTier && isSoftwareEnabled && hasSelfService && (
<TabPanel>
<SelfService
contactUrl={orgContactURL}
deviceToken={deviceAuthToken}
isSoftwareEnabled
pathname={location.pathname}
queryParams={parseHostSoftwareQueryParams(location.query)}
router={router}
/>
</TabPanel>
)}
<TabPanel className={`${baseClass}__details-panel`}>
<HostSummaryCard
className={fullWidthCardClass}
summaryData={summaryData}
bootstrapPackageData={bootstrapPackageData}
isPremiumTier={isPremiumTier}
toggleOSSettingsModal={toggleOSSettingsModal}
hostSettings={host?.mdm.profiles ?? []}
osSettings={host?.mdm.os_settings}
/>
<AboutCard
className={
showUsersCard ? defaultCardClass : fullWidthCardClass
@ -520,18 +537,6 @@ const DeviceUserPage = ({
/>
)}
</TabPanel>
{isPremiumTier && isSoftwareEnabled && hasSelfService && (
<TabPanel>
<SelfService
contactUrl={orgContactURL}
deviceToken={deviceAuthToken}
isSoftwareEnabled
pathname={location.pathname}
queryParams={parseHostSoftwareQueryParams(location.query)}
router={router}
/>
</TabPanel>
)}
{isSoftwareEnabled && (
<TabPanel>
<SoftwareCard

View file

@ -112,6 +112,7 @@ import {
generateOtherEmailsValues,
generateUsernameValues,
} from "../cards/User/helpers";
import HostHeader from "../cards/HostHeader";
const baseClass = "host-details";
@ -916,22 +917,15 @@ const HostDetailsPage = ({
path={filteredHostsPath || PATHS.MANAGE_HOSTS}
/>
</div>
<HostSummaryCard
summaryData={summaryData}
bootstrapPackageData={bootstrapPackageData}
isPremiumTier={isPremiumTier}
toggleOSSettingsModal={toggleOSSettingsModal}
toggleBootstrapPackageModal={toggleBootstrapPackageModal}
hostSettings={host?.mdm.profiles ?? []}
showRefetchSpinner={showRefetchSpinner}
onRefetchHost={onRefetchHost}
renderActionDropdown={renderActionDropdown}
osSettings={host?.mdm.os_settings}
osVersionRequirement={getOSVersionRequirementFromMDMConfig(
host.platform
)}
hostMdmDeviceStatus={hostMdmDeviceStatus}
/>
<div className={`${baseClass}__header-summary`}>
<HostHeader
summaryData={summaryData}
showRefetchSpinner={showRefetchSpinner}
onRefetchHost={onRefetchHost}
renderActionDropdown={renderActionDropdown}
hostMdmDeviceStatus={hostMdmDeviceStatus}
/>
</div>
<TabNav className={`${baseClass}__tab-nav`}>
<Tabs
selectedIndex={getTabIndex(location.pathname)}
@ -951,6 +945,19 @@ const HostDetailsPage = ({
})}
</TabList>
<TabPanel className={`${baseClass}__details-panel`}>
<HostSummaryCard
summaryData={summaryData}
bootstrapPackageData={bootstrapPackageData}
isPremiumTier={isPremiumTier}
toggleOSSettingsModal={toggleOSSettingsModal}
toggleBootstrapPackageModal={toggleBootstrapPackageModal}
hostSettings={host?.mdm.profiles ?? []}
osSettings={host?.mdm.os_settings}
osVersionRequirement={getOSVersionRequirementFromMDMConfig(
host.platform
)}
className={fullWidthCardClass}
/>
<AboutCard
className={
showUsersCard ? defaultCardClass : fullWidthCardClass

View file

@ -5,6 +5,12 @@
gap: $pad-medium;
}
&__header-summary {
display: flex;
flex-direction: column;
gap: $pad-medium;
}
@media screen and (min-width: $break-md) {
&__details-panel.react-tabs__tab-panel--selected {
// Must be selected to show grid

View file

@ -10,7 +10,6 @@
@include color-contrasted-sections;
.header {
flex: 100%;
display: flex;
flex-direction: column;
}

View file

@ -0,0 +1,117 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import HostHeader from "./HostHeader";
import { HostMdmDeviceStatusUIState } from "../../helpers";
const renderActionDropdown = jest.fn(() => <div data-testid="dropdown" />);
const defaultSummaryData = {
platform: "darwin",
status: "online",
display_name: "Test Host",
detail_updated_at: "2024-04-27T12:00:00Z",
};
describe("HostHeader", () => {
it("renders host display name and last fetched", () => {
render(
<HostHeader
summaryData={defaultSummaryData}
showRefetchSpinner={false}
onRefetchHost={jest.fn()}
renderActionDropdown={renderActionDropdown}
/>
);
expect(screen.getByText("Test Host")).toBeInTheDocument();
expect(screen.getByText(/Last fetched/i)).toBeInTheDocument();
});
it("renders 'My device' when deviceUser is true and unavailable when no last fetched date", () => {
render(
<HostHeader
summaryData={{ ...defaultSummaryData, detail_updated_at: undefined }}
showRefetchSpinner={false}
onRefetchHost={jest.fn()}
renderActionDropdown={renderActionDropdown}
deviceUser
/>
);
expect(screen.getByText("My device")).toBeInTheDocument();
expect(screen.getByText(/unavailable/i)).toBeInTheDocument();
});
it("does not render refetch button for Android", () => {
render(
<HostHeader
summaryData={{ ...defaultSummaryData, platform: "android" }}
showRefetchSpinner={false}
onRefetchHost={jest.fn()}
renderActionDropdown={renderActionDropdown}
/>
);
expect(screen.queryByText("Refetch")).not.toBeInTheDocument();
});
it("disables refetch button when host is offline", () => {
render(
<HostHeader
summaryData={{ ...defaultSummaryData, status: "offline" }}
showRefetchSpinner={false}
onRefetchHost={jest.fn()}
renderActionDropdown={renderActionDropdown}
/>
);
const refetchButton = screen.getByRole("button", { name: /refetch/i });
expect(refetchButton).toBeDisabled();
});
it("shows refetch spinner text when fetching", () => {
render(
<HostHeader
summaryData={defaultSummaryData}
showRefetchSpinner
onRefetchHost={jest.fn()}
renderActionDropdown={renderActionDropdown}
/>
);
expect(screen.getByText(/Fetching fresh vitals/i)).toBeInTheDocument();
});
it("calls onRefetchHost when refetch button is clicked", () => {
const onRefetchHost = jest.fn();
render(
<HostHeader
summaryData={defaultSummaryData}
showRefetchSpinner={false}
onRefetchHost={onRefetchHost}
renderActionDropdown={renderActionDropdown}
/>
);
fireEvent.click(screen.getByText("Refetch"));
expect(onRefetchHost).toHaveBeenCalled();
});
it("shows tooltip when host is offline", () => {
render(
<HostHeader
summaryData={{ ...defaultSummaryData, status: "offline" }}
showRefetchSpinner={false}
onRefetchHost={jest.fn()}
renderActionDropdown={renderActionDropdown}
/>
);
expect(screen.getByText(/an offline host/i)).toBeInTheDocument();
});
it("renders device status tag and tooltip if hostMdmDeviceStatus is set", () => {
render(
<HostHeader
summaryData={defaultSummaryData}
showRefetchSpinner={false}
onRefetchHost={jest.fn()}
renderActionDropdown={renderActionDropdown}
hostMdmDeviceStatus={"locked" as HostMdmDeviceStatusUIState}
/>
);
expect(screen.getByText(/a locked host/i)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,223 @@
import React, { useRef } from "react";
import ReactTooltip from "react-tooltip";
import classnames from "classnames";
import { isAndroid } from "interfaces/platform";
import Button from "components/buttons/Button";
import Icon from "components/Icon/Icon";
import { HumanTimeDiffWithFleetLaunchCutoff } from "components/HumanTimeDiffWithDateTip";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
import { COLORS } from "styles/var/colors";
import { useCheckTruncatedElement } from "hooks/useCheckTruncatedElement";
import TooltipWrapper from "components/TooltipWrapper";
import { HostMdmDeviceStatusUIState } from "../../helpers";
import { DEVICE_STATUS_TAGS, REFETCH_TOOLTIP_MESSAGES } from "./helpers";
const baseClass = "host-header";
interface IRefetchButtonProps {
isDisabled: boolean;
isFetching: boolean;
tooltip?: React.ReactNode;
onRefetchHost: (
evt: React.MouseEvent<HTMLButtonElement, React.MouseEvent>
) => void;
}
const RefetchButton = ({
isDisabled,
isFetching,
tooltip,
onRefetchHost,
}: IRefetchButtonProps) => {
const classNames = classnames({
tooltip: isDisabled,
"refetch-spinner": isFetching,
"refetch-btn": !isFetching,
});
const buttonText = isFetching
? "Fetching fresh vitals...this may take a moment"
: "Refetch";
// add additonal props when we need to display a tooltip for the button
const conditionalProps: { "data-tip"?: boolean; "data-for"?: string } = {};
if (tooltip) {
conditionalProps["data-tip"] = true;
conditionalProps["data-for"] = "refetch-tooltip";
}
const renderTooltip = () => {
return (
<ReactTooltip
place="top"
effect="solid"
id="refetch-tooltip"
backgroundColor={COLORS["tooltip-bg"]}
>
<span className={`${baseClass}__tooltip-text`}>{tooltip}</span>
</ReactTooltip>
);
};
return (
<>
<div className={`${baseClass}__refetch`} {...conditionalProps}>
<Button
className={classNames}
disabled={isDisabled}
onClick={onRefetchHost}
variant="text-icon"
>
<Icon name="refresh" color="core-fleet-blue" size="small" />
{buttonText}
</Button>
{tooltip && renderTooltip()}
</div>
</>
);
};
interface IHostSummaryProps {
summaryData: any; // TODO: create interfaces for this and use consistently across host pages and related helpers
showRefetchSpinner: boolean;
onRefetchHost: (
evt: React.MouseEvent<HTMLButtonElement, React.MouseEvent>
) => void;
renderActionDropdown: () => JSX.Element | null;
deviceUser?: boolean;
hostMdmDeviceStatus?: HostMdmDeviceStatusUIState;
}
const HostHeader = ({
summaryData,
showRefetchSpinner,
onRefetchHost,
renderActionDropdown,
deviceUser,
hostMdmDeviceStatus,
}: IHostSummaryProps): JSX.Element => {
const { platform } = summaryData;
const isAndroidHost = isAndroid(platform);
const isIosOrIpadosHost = platform === "ios" || platform === "ipados";
const hostDisplayName = useRef<HTMLHeadingElement>(null);
const isTruncated = useCheckTruncatedElement(hostDisplayName);
const renderRefetch = () => {
if (isAndroidHost) {
return null;
}
const isOnline = summaryData.status === "online";
let isDisabled = false;
let tooltip;
// we don't have a concept of "online" for iPads and iPhones, so always enable refetch
if (!isIosOrIpadosHost) {
// deviceStatus can be `undefined` in the case of the MyDevice Page not sending
// this prop. When this is the case or when it is `unlocked`, we only take
// into account the host being online or offline for correctly render the
// refresh button. If we have a value for deviceStatus, we then need to also
// take it account for rendering the button.
if (
hostMdmDeviceStatus === undefined ||
hostMdmDeviceStatus === "unlocked"
) {
isDisabled = !isOnline;
tooltip = !isOnline ? REFETCH_TOOLTIP_MESSAGES.offline : null;
} else {
isDisabled = true;
tooltip = !isOnline
? REFETCH_TOOLTIP_MESSAGES.offline
: REFETCH_TOOLTIP_MESSAGES[hostMdmDeviceStatus];
}
}
return (
<RefetchButton
isDisabled={isDisabled}
isFetching={showRefetchSpinner}
tooltip={tooltip}
onRefetchHost={onRefetchHost}
/>
);
};
const lastFetched = summaryData.detail_updated_at ? (
<HumanTimeDiffWithFleetLaunchCutoff
timeString={summaryData.detail_updated_at}
/>
) : (
": unavailable"
);
const renderDeviceStatusTag = () => {
if (!hostMdmDeviceStatus || hostMdmDeviceStatus === "unlocked") return null;
const tag = DEVICE_STATUS_TAGS[hostMdmDeviceStatus];
const classNames = classnames(
`${baseClass}__device-status-tag`,
tag.tagType
);
return (
<>
<span className={classNames} data-tip data-for="tag-tooltip">
{tag.title}
</span>
<ReactTooltip
place="top"
effect="solid"
id="tag-tooltip"
backgroundColor={COLORS["tooltip-bg"]}
>
<span className={`${baseClass}__tooltip-text`}>
{tag.generateTooltip(platform)}
</span>
</ReactTooltip>
</>
);
};
return (
<div className="header title">
<div className="title__inner">
<div className="display-name-container">
<TooltipWrapper
disableTooltip={!isTruncated}
tipContent={
deviceUser
? "My device"
: summaryData.display_name || DEFAULT_EMPTY_CELL_VALUE
}
underline={false}
position="top"
showArrow
>
<h1 className="display-name" ref={hostDisplayName}>
{deviceUser
? "My device"
: summaryData.display_name || DEFAULT_EMPTY_CELL_VALUE}
</h1>
</TooltipWrapper>
{renderDeviceStatusTag()}
<div className={`${baseClass}__last-fetched`}>
{"Last fetched"} {lastFetched}
&nbsp;
</div>
{renderRefetch()}
</div>
</div>
{renderActionDropdown()}
</div>
);
};
export default HostHeader;

View file

@ -0,0 +1,71 @@
.host-header {
&__device-status-tag {
margin-left: $pad-small;
background-color: $ui-warning;
padding: $pad-xsmall;
font-size: 10px;
font-weight: $bold;
border-radius: $border-radius;
&.warning {
background-color: $ui-warning;
}
&.error {
color: $core-white;
background-color: $core-vibrant-red;
}
}
&__last-fetched {
font-size: $xx-small;
color: $core-fleet-black;
margin: 0;
margin-left: $pad-medium;
}
&__refetch {
display: flex;
margin-left: $pad-medium;
margin-right: $pad-small;
.refetch-btn {
font-size: $x-small;
height: 38px;
&:hover {
svg {
path {
fill: $core-vibrant-blue-over;
}
}
}
}
.refetch-spinner {
color: $core-vibrant-blue;
cursor: default;
font-size: $x-small;
height: 38px;
opacity: 50%;
filter: saturate(100%);
margin-left: $pad-small;
.icon {
animation: spin 2s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
transform-origin: center center;
}
100% {
transform: rotate(360deg);
transform-origin: center center;
}
}
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./HostHeader";

View file

@ -1,11 +1,11 @@
import React from "react";
import { noop } from "lodash";
import { screen, fireEvent } from "@testing-library/react";
import { createCustomRenderer } from "test/test-utils";
import createMockUser from "__mocks__/userMock";
import { createMockHostSummary } from "__mocks__/hostMock";
import { BootstrapPackageStatus } from "interfaces/mdm";
import HostSummary from "./HostSummary";
describe("Host Summary section", () => {
@ -22,18 +22,95 @@ describe("Host Summary section", () => {
});
const summaryData = createMockHostSummary({});
render(
<HostSummary
summaryData={summaryData}
showRefetchSpinner={false}
onRefetchHost={noop}
renderActionDropdown={() => null}
/>
);
render(<HostSummary summaryData={summaryData} />);
expect(screen.queryByText("Issues")).not.toBeInTheDocument();
});
});
describe("Team data", () => {
it("renders the team name when present", () => {
const render = createCustomRenderer({
context: {
app: {
isPremiumTier: true,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
},
});
const summaryData = createMockHostSummary({ team_name: "Engineering" });
render(<HostSummary summaryData={summaryData} isPremiumTier />);
expect(screen.getByText("Team").nextElementSibling).toHaveTextContent(
"Engineering"
);
});
it("renders 'No team' when team_name is '---'", () => {
const render = createCustomRenderer({
/* ...context... */
});
const summaryData = createMockHostSummary({ team_name: "---" });
render(<HostSummary summaryData={summaryData} isPremiumTier />);
expect(screen.getByText("No team")).toBeInTheDocument();
});
});
describe("Disk encryption data", () => {
it("renders 'On' for macOS when enabled", () => {
const render = createCustomRenderer({
context: {
app: {
isPremiumTier: true,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
},
});
const summaryData = createMockHostSummary({
platform: "darwin",
disk_encryption_enabled: true,
});
render(<HostSummary summaryData={summaryData} />);
expect(screen.getByText("Disk encryption")).toBeInTheDocument();
expect(screen.getByText("On")).toBeInTheDocument();
});
it("renders 'Off' for Windows when disabled", () => {
const render = createCustomRenderer({
context: {
app: {
isPremiumTier: true,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
},
});
const summaryData = createMockHostSummary({
platform: "windows",
disk_encryption_enabled: false,
});
render(<HostSummary summaryData={summaryData} />);
expect(screen.getByText("Disk encryption")).toBeInTheDocument();
expect(screen.getByText("Off")).toBeInTheDocument();
});
it("renders Chromebook message for Chrome platform", () => {
const render = createCustomRenderer({
context: {
app: {
isPremiumTier: true,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
},
});
const summaryData = createMockHostSummary({ platform: "chrome" });
render(<HostSummary summaryData={summaryData} />);
expect(screen.getByText("Always on")).toBeInTheDocument();
});
});
describe("Agent data", () => {
it("with all info present, render Agent header with orbit_version and tooltip with all 3 data points", async () => {
const render = createCustomRenderer({
@ -50,14 +127,7 @@ describe("Host Summary section", () => {
const osqueryVersion = summaryData.osquery_version as string;
const fleetdVersion = summaryData.fleet_desktop_version as string;
render(
<HostSummary
summaryData={summaryData}
showRefetchSpinner={false}
onRefetchHost={noop}
renderActionDropdown={() => null}
/>
);
render(<HostSummary summaryData={summaryData} />);
expect(screen.getByText("Agent")).toBeInTheDocument();
@ -89,14 +159,7 @@ describe("Host Summary section", () => {
const orbitVersion = summaryData.orbit_version as string;
const osqueryVersion = summaryData.osquery_version as string;
render(
<HostSummary
summaryData={summaryData}
showRefetchSpinner={false}
onRefetchHost={noop}
renderActionDropdown={() => null}
/>
);
render(<HostSummary summaryData={summaryData} />);
expect(screen.getByText("Agent")).toBeInTheDocument();
@ -127,20 +190,14 @@ describe("Host Summary section", () => {
const fleetdChromeVersion = summaryData.osquery_version as string;
const { user } = render(
<HostSummary
summaryData={summaryData}
showRefetchSpinner={false}
onRefetchHost={noop}
renderActionDropdown={() => null}
/>
);
const { user } = render(<HostSummary summaryData={summaryData} />);
expect(screen.getByText("Agent")).toBeInTheDocument();
await user.hover(screen.getByText(new RegExp(fleetdChromeVersion, "i")));
expect(screen.queryByText("Osquery")).not.toBeInTheDocument();
});
});
describe("iOS and iPadOS data", () => {
it("for iOS, renders Team, Disk space, and Operating system data only", async () => {
const render = createCustomRenderer({
@ -164,15 +221,7 @@ describe("Host Summary section", () => {
const diskSpaceAvailable = summaryData.gigs_disk_space_available as string;
const osVersion = summaryData.os_version as string;
render(
<HostSummary
summaryData={summaryData}
showRefetchSpinner={false}
onRefetchHost={noop}
renderActionDropdown={() => null}
isPremiumTier
/>
);
render(<HostSummary summaryData={summaryData} isPremiumTier />);
expect(screen.getByText("Team").nextElementSibling).toHaveTextContent(
teamName
@ -183,7 +232,6 @@ describe("Host Summary section", () => {
expect(
screen.getByText("Operating system").nextElementSibling
).toHaveTextContent(osVersion);
expect(screen.queryByText("Refetch")).toBeInTheDocument();
expect(screen.queryByText("Status")).not.toBeInTheDocument();
expect(screen.queryByText("Memory")).not.toBeInTheDocument();
@ -213,15 +261,7 @@ describe("Host Summary section", () => {
const diskSpaceAvailable = summaryData.gigs_disk_space_available as string;
const osVersion = summaryData.os_version as string;
render(
<HostSummary
summaryData={summaryData}
showRefetchSpinner={false}
onRefetchHost={noop}
renderActionDropdown={() => null}
isPremiumTier
/>
);
render(<HostSummary summaryData={summaryData} isPremiumTier />);
expect(screen.getByText("Team").nextElementSibling).toHaveTextContent(
teamName
@ -232,7 +272,6 @@ describe("Host Summary section", () => {
expect(
screen.getByText("Operating system").nextElementSibling
).toHaveTextContent(osVersion);
expect(screen.queryByText("Refetch")).toBeInTheDocument();
expect(screen.queryByText("Status")).not.toBeInTheDocument();
expect(screen.queryByText("Memory")).not.toBeInTheDocument();
@ -241,6 +280,7 @@ describe("Host Summary section", () => {
expect(screen.queryByText("Osquery")).not.toBeInTheDocument();
});
});
describe("Maintenance window data", () => {
it("renders maintenance window data with timezone", async () => {
const render = createCustomRenderer({
@ -261,18 +301,37 @@ describe("Host Summary section", () => {
});
const prettyStartTime = /Jun 24 at 8:48 PM/;
render(
<HostSummary
summaryData={summaryData}
showRefetchSpinner={false}
onRefetchHost={noop}
renderActionDropdown={() => null}
isPremiumTier
/>
);
render(<HostSummary summaryData={summaryData} isPremiumTier />);
expect(screen.getByText("Scheduled maintenance")).toBeInTheDocument();
expect(screen.getByText(prettyStartTime)).toBeInTheDocument();
});
});
describe("Bootstrap package data", () => {
it("renders Bootstrap package indicator when status is present", () => {
const toggleBootstrapPackageModal = jest.fn();
const render = createCustomRenderer({
context: {
app: {
isPremiumTier: true,
isGlobalAdmin: true,
currentUser: createMockUser(),
},
},
});
const summaryData = createMockHostSummary({ platform: "darwin" });
const bootstrapPackageData = {
status: "installed" as BootstrapPackageStatus,
};
render(
<HostSummary
summaryData={summaryData}
bootstrapPackageData={bootstrapPackageData}
toggleBootstrapPackageModal={toggleBootstrapPackageModal}
/>
);
expect(screen.getByText("Bootstrap package")).toBeInTheDocument();
});
});
});

View file

@ -21,14 +21,12 @@ import {
import getHostStatusTooltipText from "pages/hosts/helpers";
import TooltipWrapper from "components/TooltipWrapper";
import Button from "components/buttons/Button";
import Icon from "components/Icon/Icon";
import Card from "components/Card";
import DataSet from "components/DataSet";
import StatusIndicator from "components/StatusIndicator";
import IssuesIndicator from "pages/hosts/components/IssuesIndicator";
import DiskSpaceIndicator from "pages/hosts/components/DiskSpaceIndicator";
import { HumanTimeDiffWithFleetLaunchCutoff } from "components/HumanTimeDiffWithDateTip";
import {
humanHostMemory,
wrapFleetHelper,
@ -40,83 +38,16 @@ import {
DEFAULT_EMPTY_CELL_VALUE,
} from "utilities/constants";
import { useCheckTruncatedElement } from "hooks/useCheckTruncatedElement";
import { COLORS } from "styles/var/colors";
import OSSettingsIndicator from "./OSSettingsIndicator";
import BootstrapPackageIndicator from "./BootstrapPackageIndicator/BootstrapPackageIndicator";
import {
HostMdmDeviceStatusUIState,
generateLinuxDiskEncryptionSetting,
generateWinDiskEncryptionSetting,
} from "../../helpers";
import { DEVICE_STATUS_TAGS, REFETCH_TOOLTIP_MESSAGES } from "./helpers";
const baseClass = "host-summary";
interface IRefetchButtonProps {
isDisabled: boolean;
isFetching: boolean;
tooltip?: React.ReactNode;
onRefetchHost: (
evt: React.MouseEvent<HTMLButtonElement, React.MouseEvent>
) => void;
}
const RefetchButton = ({
isDisabled,
isFetching,
tooltip,
onRefetchHost,
}: IRefetchButtonProps) => {
const classNames = classnames({
tooltip: isDisabled,
"refetch-spinner": isFetching,
"refetch-btn": !isFetching,
});
const buttonText = isFetching
? "Fetching fresh vitals...this may take a moment"
: "Refetch";
// add additonal props when we need to display a tooltip for the button
const conditionalProps: { "data-tip"?: boolean; "data-for"?: string } = {};
if (tooltip) {
conditionalProps["data-tip"] = true;
conditionalProps["data-for"] = "refetch-tooltip";
}
const renderTooltip = () => {
return (
<ReactTooltip
place="top"
effect="solid"
id="refetch-tooltip"
backgroundColor={COLORS["tooltip-bg"]}
>
<span className={`${baseClass}__tooltip-text`}>{tooltip}</span>
</ReactTooltip>
);
};
return (
<>
<div className={`${baseClass}__refetch`} {...conditionalProps}>
<Button
className={classNames}
disabled={isDisabled}
onClick={onRefetchHost}
variant="text-icon"
>
<Icon name="refresh" color="core-fleet-blue" size="small" />
{buttonText}
</Button>
{tooltip && renderTooltip()}
</div>
</>
);
};
const baseClass = "host-summary-card";
interface IBootstrapPackageData {
status?: BootstrapPackageStatus | "";
@ -130,15 +61,9 @@ interface IHostSummaryProps {
toggleOSSettingsModal?: () => void;
toggleBootstrapPackageModal?: () => void;
hostSettings?: IHostMdmProfile[];
showRefetchSpinner: boolean;
onRefetchHost: (
evt: React.MouseEvent<HTMLButtonElement, React.MouseEvent>
) => void;
renderActionDropdown: () => JSX.Element | null;
deviceUser?: boolean;
osVersionRequirement?: IAppleDeviceUpdates;
osSettings?: IOSSettings;
hostMdmDeviceStatus?: HostMdmDeviceStatusUIState;
className?: string;
}
const DISK_ENCRYPTION_MESSAGES = {
@ -198,16 +123,13 @@ const HostSummary = ({
toggleOSSettingsModal,
toggleBootstrapPackageModal,
hostSettings,
showRefetchSpinner,
onRefetchHost,
renderActionDropdown,
deviceUser,
osVersionRequirement,
osSettings,
hostMdmDeviceStatus,
className,
}: IHostSummaryProps): JSX.Element => {
const hostDisplayName = useRef<HTMLHeadingElement>(null);
const isTruncated = useCheckTruncatedElement(hostDisplayName);
const classNames = classnames(baseClass, className);
const {
status,
@ -220,46 +142,6 @@ const HostSummary = ({
const isChromeHost = platform === "chrome";
const isIosOrIpadosHost = platform === "ios" || platform === "ipados";
const renderRefetch = () => {
if (isAndroidHost) {
return null;
}
const isOnline = summaryData.status === "online";
let isDisabled = false;
let tooltip;
// we don't have a concept of "online" for iPads and iPhones, so always enable refetch
if (!isIosOrIpadosHost) {
// deviceStatus can be `undefined` in the case of the MyDevice Page not sending
// this prop. When this is the case or when it is `unlocked`, we only take
// into account the host being online or offline for correctly render the
// refresh button. If we have a value for deviceStatus, we then need to also
// take it account for rendering the button.
if (
hostMdmDeviceStatus === undefined ||
hostMdmDeviceStatus === "unlocked"
) {
isDisabled = !isOnline;
tooltip = !isOnline ? REFETCH_TOOLTIP_MESSAGES.offline : null;
} else {
isDisabled = true;
tooltip = !isOnline
? REFETCH_TOOLTIP_MESSAGES.offline
: REFETCH_TOOLTIP_MESSAGES[hostMdmDeviceStatus];
}
}
return (
<RefetchButton
isDisabled={isDisabled}
isFetching={showRefetchSpinner}
tooltip={tooltip}
onRefetchHost={onRefetchHost}
/>
);
};
const renderIssues = () => (
<DataSet
title="Issues"
@ -470,187 +352,108 @@ const HostSummary = ({
);
};
const renderSummary = () => {
// for windows and linux hosts we have to manually add a profile for disk encryption
// as this is not currently included in the `profiles` value from the API
// response for windows and linux hosts.
if (
platform === "windows" &&
osSettings?.disk_encryption?.status &&
isWindowsDiskEncryptionStatus(osSettings.disk_encryption.status)
) {
const winDiskEncryptionSetting: IHostMdmProfile = generateWinDiskEncryptionSetting(
osSettings.disk_encryption.status,
osSettings.disk_encryption.detail
);
hostSettings = hostSettings
? [...hostSettings, winDiskEncryptionSetting]
: [winDiskEncryptionSetting];
}
// for windows and linux hosts we have to manually add a profile for disk encryption
// as this is not currently included in the `profiles` value from the API
// response for windows and linux hosts.
if (
platform === "windows" &&
osSettings?.disk_encryption?.status &&
isWindowsDiskEncryptionStatus(osSettings.disk_encryption.status)
) {
const winDiskEncryptionSetting: IHostMdmProfile = generateWinDiskEncryptionSetting(
osSettings.disk_encryption.status,
osSettings.disk_encryption.detail
);
hostSettings = hostSettings
? [...hostSettings, winDiskEncryptionSetting]
: [winDiskEncryptionSetting];
}
if (
isDiskEncryptionSupportedLinuxPlatform(platform, os_version) &&
osSettings?.disk_encryption?.status &&
isLinuxDiskEncryptionStatus(osSettings.disk_encryption.status)
) {
const linuxDiskEncryptionSetting: IHostMdmProfile = generateLinuxDiskEncryptionSetting(
osSettings.disk_encryption.status,
osSettings.disk_encryption.detail
);
hostSettings = hostSettings
? [...hostSettings, linuxDiskEncryptionSetting]
: [linuxDiskEncryptionSetting];
}
if (
isDiskEncryptionSupportedLinuxPlatform(platform, os_version) &&
osSettings?.disk_encryption?.status &&
isLinuxDiskEncryptionStatus(osSettings.disk_encryption.status)
) {
const linuxDiskEncryptionSetting: IHostMdmProfile = generateLinuxDiskEncryptionSetting(
osSettings.disk_encryption.status,
osSettings.disk_encryption.detail
);
hostSettings = hostSettings
? [...hostSettings, linuxDiskEncryptionSetting]
: [linuxDiskEncryptionSetting];
}
return (
<Card
borderRadiusSize="xxlarge"
paddingSize="xlarge"
includeShadow
className={`${baseClass}-card`}
>
{!isIosOrIpadosHost && !isAndroidHost && (
return (
<Card
borderRadiusSize="xxlarge"
paddingSize="xlarge"
includeShadow
className={classNames}
>
{!isIosOrIpadosHost && !isAndroidHost && (
<DataSet
title="Status"
value={
<StatusIndicator
value={status || ""} // temporary work around of integration test bug
tooltip={{
tooltipText: getHostStatusTooltipText(status),
position: "bottom",
}}
/>
}
/>
)}
{summaryData.issues?.total_issues_count > 0 &&
!isIosOrIpadosHost &&
!isAndroidHost &&
renderIssues()}
{isPremiumTier && renderHostTeam()}
{/* Rendering of OS Settings data */}
{isOsSettingsDisplayPlatform(platform, os_version) &&
isPremiumTier &&
hostSettings &&
hostSettings.length > 0 && (
<DataSet
title="Status"
title="OS settings"
value={
<StatusIndicator
value={status || ""} // temporary work around of integration test bug
tooltip={{
tooltipText: getHostStatusTooltipText(status),
position: "bottom",
}}
<OSSettingsIndicator
profiles={hostSettings}
onClick={toggleOSSettingsModal}
/>
}
/>
)}
{summaryData.issues?.total_issues_count > 0 &&
!isIosOrIpadosHost &&
!isAndroidHost &&
renderIssues()}
{isPremiumTier && renderHostTeam()}
{/* Rendering of OS Settings data */}
{isOsSettingsDisplayPlatform(platform, os_version) &&
isPremiumTier &&
hostSettings &&
hostSettings.length > 0 && (
<DataSet
title="OS settings"
value={
<OSSettingsIndicator
profiles={hostSettings}
onClick={toggleOSSettingsModal}
/>
}
{bootstrapPackageData?.status && !isIosOrIpadosHost && !isAndroidHost && (
<DataSet
title="Bootstrap package"
value={
<BootstrapPackageIndicator
status={bootstrapPackageData.status}
onClick={toggleBootstrapPackageModal}
/>
)}
{bootstrapPackageData?.status &&
!isIosOrIpadosHost &&
!isAndroidHost && (
<DataSet
title="Bootstrap package"
value={
<BootstrapPackageIndicator
status={bootstrapPackageData.status}
onClick={toggleBootstrapPackageModal}
/>
}
/>
)}
{!isChromeHost && renderDiskSpaceSummary()}
{renderDiskEncryptionSummary()}
{!isIosOrIpadosHost && (
<DataSet
title="Memory"
value={wrapFleetHelper(humanHostMemory, summaryData.memory)}
/>
)}
{!isIosOrIpadosHost && (
<DataSet title="Processor type" value={summaryData.cpu_type} />
)}
{renderOperatingSystemSummary()}
{renderAgentSummary()}
{isPremiumTier &&
// TODO - refactor normalizeEmptyValues pattern
!!summaryData.maintenance_window &&
summaryData.maintenance_window !== "---" &&
renderMaintenanceWindow(summaryData.maintenance_window)}
</Card>
);
};
const lastFetched = summaryData.detail_updated_at ? (
<HumanTimeDiffWithFleetLaunchCutoff
timeString={summaryData.detail_updated_at}
/>
) : (
": unavailable"
);
const renderDeviceStatusTag = () => {
if (!hostMdmDeviceStatus || hostMdmDeviceStatus === "unlocked") return null;
const tag = DEVICE_STATUS_TAGS[hostMdmDeviceStatus];
const classNames = classnames(
`${baseClass}__device-status-tag`,
tag.tagType
);
return (
<>
<span className={classNames} data-tip data-for="tag-tooltip">
{tag.title}
</span>
<ReactTooltip
place="top"
effect="solid"
id="tag-tooltip"
backgroundColor={COLORS["tooltip-bg"]}
>
<span className={`${baseClass}__tooltip-text`}>
{tag.generateTooltip(platform)}
</span>
</ReactTooltip>
</>
);
};
return (
<div className={baseClass}>
<div className="header title">
<div className="title__inner">
<div className="display-name-container">
<TooltipWrapper
disableTooltip={!isTruncated}
tipContent={
deviceUser
? "My device"
: summaryData.display_name || DEFAULT_EMPTY_CELL_VALUE
}
underline={false}
position="top"
showArrow
>
<h1 className="display-name" ref={hostDisplayName}>
{deviceUser
? "My device"
: summaryData.display_name || DEFAULT_EMPTY_CELL_VALUE}
</h1>
</TooltipWrapper>
{renderDeviceStatusTag()}
<div className={`${baseClass}__last-fetched`}>
{"Last fetched"} {lastFetched}
&nbsp;
</div>
{renderRefetch()}
</div>
</div>
{renderActionDropdown()}
</div>
{renderSummary()}
</div>
}
/>
)}
{!isChromeHost && renderDiskSpaceSummary()}
{renderDiskEncryptionSummary()}
{!isIosOrIpadosHost && (
<DataSet
title="Memory"
value={wrapFleetHelper(humanHostMemory, summaryData.memory)}
/>
)}
{!isIosOrIpadosHost && (
<DataSet title="Processor type" value={summaryData.cpu_type} />
)}
{renderOperatingSystemSummary()}
{renderAgentSummary()}
{isPremiumTier &&
// TODO - refactor normalizeEmptyValues pattern
!!summaryData.maintenance_window &&
summaryData.maintenance_window !== "---" &&
renderMaintenanceWindow(summaryData.maintenance_window)}
</Card>
);
};

View file

@ -1,108 +1,19 @@
.host-summary {
.host-summary-card {
display: flex;
flex-direction: column;
gap: $pad-medium;
gap: $pad-medium $pad-xxlarge;
padding: $pad-xxlarge;
flex-wrap: wrap;
&__device-status-tag {
margin-left: $pad-small;
background-color: $ui-warning;
padding: $pad-xsmall;
font-size: 10px;
font-weight: $bold;
border-radius: $border-radius;
&.warning {
background-color: $ui-warning;
}
&.error {
color: $core-white;
background-color: $core-vibrant-red;
}
}
&__last-fetched {
font-size: $xx-small;
color: $core-fleet-black;
margin: 0;
margin-left: $pad-medium;
}
&__refetch {
// Properly vertically aligns host issue icon
.host-issue {
display: flex;
margin-left: $pad-medium;
margin-right: $pad-small;
.refetch-btn {
font-size: $x-small;
height: 38px;
&:hover {
svg {
path {
fill: $core-vibrant-blue-over;
}
}
}
}
.refetch-spinner {
color: $core-vibrant-blue;
cursor: default;
font-size: $x-small;
height: 38px;
opacity: 50%;
filter: saturate(100%);
margin-left: $pad-small;
.icon {
animation: spin 2s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
transform-origin: center center;
}
100% {
transform: rotate(360deg);
transform-origin: center center;
}
}
}
gap: $pad-xsmall;
}
.card {
&__header {
display: flex;
align-items: center;
gap: $pad-xxsmall;
max-height: 20px;
.premium-icon-tip {
position: relative;
top: 3px;
}
}
.component__tooltip-wrapper__tip-text {
max-width: 326px;
}
display: flex;
gap: $pad-medium $pad-xxlarge;
padding: $pad-xxlarge;
flex-wrap: wrap;
// Properly vertically aligns host issue icon
.host-issue {
display: flex;
gap: $pad-xsmall;
}
.no-team {
color: $ui-fleet-black-50;
}
.no-team {
color: $ui-fleet-black-50;
}
.data-set dd {
overflow: initial;
}