mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 08:58:41 +00:00
Host Details Page: Render better messaging for various empty states (#5294)
This commit is contained in:
parent
f806cbc638
commit
6917331a1b
12 changed files with 162 additions and 136 deletions
1
changes/issue-3124-better-empty-states-for-host-details
Normal file
1
changes/issue-3124-better-empty-states-for-host-details
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Improve empty state messages on host details page
|
||||
|
|
@ -9,12 +9,14 @@ import classnames from "classnames";
|
|||
import { pick } from "lodash";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import configAPI from "services/entities/config";
|
||||
import hostAPI from "services/entities/hosts";
|
||||
import queryAPI from "services/entities/queries";
|
||||
import teamAPI from "services/entities/teams";
|
||||
import { AppContext } from "context/app";
|
||||
import { PolicyContext } from "context/policy";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import { IConfig } from "interfaces/config";
|
||||
import {
|
||||
IHost,
|
||||
IDeviceMappingResponse,
|
||||
|
|
@ -204,6 +206,14 @@ const HostDetailsPage = ({
|
|||
}
|
||||
);
|
||||
|
||||
const { data: hostSettings } = useQuery<
|
||||
IConfig,
|
||||
Error,
|
||||
{ enable_host_users: boolean; enable_software_inventory: boolean }
|
||||
>(["config"], () => configAPI.loadAll(), {
|
||||
select: (data: IConfig) => data.host_settings,
|
||||
});
|
||||
|
||||
const refetchExtensions = () => {
|
||||
deviceMapping !== null && refetchDeviceMapping();
|
||||
macadmins !== null && refetchMacadmins();
|
||||
|
|
@ -582,10 +592,15 @@ const HostDetailsPage = ({
|
|||
usersState={usersState}
|
||||
isLoading={isLoadingHost}
|
||||
onUsersTableSearchChange={onUsersTableSearchChange}
|
||||
hostUsersEnabled={hostSettings?.enable_host_users}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<SoftwareCard isLoading={isLoadingHost} software={hostSoftware} />
|
||||
<SoftwareCard
|
||||
isLoading={isLoadingHost}
|
||||
software={hostSoftware}
|
||||
softwareInventoryEnabled={hostSettings?.enable_software_inventory}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<ScheduleCard
|
||||
|
|
|
|||
85
frontend/pages/hosts/details/cards/EmptyState/EmptyState.tsx
Normal file
85
frontend/pages/hosts/details/cards/EmptyState/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import React from "react";
|
||||
|
||||
import ExternalLinkIcon from "../../../../../../assets/images/open-new-tab-12x12@2x.png";
|
||||
import DisableIcon from "../../../../../../assets/images/icon-action-disable-red-16x16@2x.png";
|
||||
|
||||
const baseClass = "empty-state";
|
||||
|
||||
interface IEmptyStateProps {
|
||||
title: "software" | "users";
|
||||
reason?: "empty-search" | "disabled";
|
||||
}
|
||||
|
||||
const EmptyState = ({ title, reason }: IEmptyStateProps): JSX.Element => {
|
||||
const formalTitle = () => {
|
||||
switch (title) {
|
||||
case "software":
|
||||
return "Software inventory";
|
||||
case "users":
|
||||
return "User collection";
|
||||
default:
|
||||
return "Data collection";
|
||||
}
|
||||
};
|
||||
|
||||
switch (reason) {
|
||||
case "empty-search":
|
||||
return (
|
||||
<div className={`${baseClass} empty-${title} empty-search`}>
|
||||
<div className={`${baseClass}__inner`}>
|
||||
<div className={`${baseClass}__empty-filter-results`}>
|
||||
<h1>No {title} matched your search criteria.</h1>
|
||||
<p>Try a different search.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "disabled":
|
||||
return (
|
||||
<div className={`${baseClass} empty-${title}`}>
|
||||
<div className={`${baseClass}__inner`}>
|
||||
<div className={`${baseClass}__disabled`}>
|
||||
<h1>{formalTitle()} has been disabled.</h1>
|
||||
<p>
|
||||
Check out the Fleet documentation for{" "}
|
||||
<a
|
||||
href="https://fleetdm.com/docs/using-fleet/configuration-files#host-settings"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
steps to enable this feature
|
||||
<img alt="External link" src={ExternalLinkIcon} />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className={`${baseClass} empty-${title}`}>
|
||||
<div className={`${baseClass}__inner`}>
|
||||
<div className={`${baseClass}__empty-list`}>
|
||||
<h1>
|
||||
<img alt="Disable icon" src={DisableIcon} />
|
||||
No {title} collected from this host.
|
||||
</h1>
|
||||
<p>
|
||||
Check out the Fleet documentation on{" "}
|
||||
<a
|
||||
href="https://fleetdm.com/docs/using-fleet/faq#why-is-my-host-not-returning-vitals"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
how to troubleshoot{" "}
|
||||
<img alt="External link" src={ExternalLinkIcon} />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default EmptyState;
|
||||
|
|
@ -1,23 +1,22 @@
|
|||
.empty-users {
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: 80px;
|
||||
margin-bottom: 80px;
|
||||
align-items: flex-start;
|
||||
margin: 0;
|
||||
|
||||
&__inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
h1 {
|
||||
font-size: $small;
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
margin-bottom: $pad-medium;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 176px;
|
||||
margin-right: 34px;
|
||||
margin-right: $pad-small;
|
||||
}
|
||||
|
||||
p {
|
||||
|
|
@ -36,13 +35,12 @@
|
|||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
text-decoration: none;
|
||||
margin-left: $pad-xsmall;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: 6px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -52,3 +50,8 @@
|
|||
width: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-search {
|
||||
margin-top: $pad-small;
|
||||
align-items: center;
|
||||
}
|
||||
1
frontend/pages/hosts/details/cards/EmptyState/index.ts
Normal file
1
frontend/pages/hosts/details/cards/EmptyState/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./EmptyState";
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
const baseClass = "empty-software";
|
||||
|
||||
const EmptySoftware = (): JSX.Element => {
|
||||
return (
|
||||
<div className={`${baseClass}`}>
|
||||
<div className={`${baseClass}__inner`}>
|
||||
<div className={`${baseClass}__empty-filter-results`}>
|
||||
<h1>No software matched your search criteria.</h1>
|
||||
<p>Try a different search.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptySoftware;
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
.empty-software {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: $pad-xxxlarge;
|
||||
margin-bottom: $pad-xxxlarge;
|
||||
|
||||
&__inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
h1 {
|
||||
font-size: $small;
|
||||
font-weight: $bold;
|
||||
margin-bottom: $pad-medium;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 176px;
|
||||
margin-right: 34px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: $core-fleet-black;
|
||||
font-weight: $regular;
|
||||
font-size: $x-small;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p.learn-more {
|
||||
margin-top: $pad-medium;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $core-vibrant-blue;
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
text-decoration: none;
|
||||
margin-left: $pad-xsmall;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
&__empty-filter-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 350px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./EmptySoftware";
|
||||
|
|
@ -8,7 +8,7 @@ import { VULNERABLE_DROPDOWN_OPTIONS } from "utilities/constants";
|
|||
import Dropdown from "components/forms/fields/Dropdown";
|
||||
import TableContainer from "components/TableContainer";
|
||||
|
||||
import EmptySoftware from "./EmptySoftware";
|
||||
import EmptyState from "../EmptyState";
|
||||
import SoftwareVulnCount from "./SoftwareVulnCount";
|
||||
|
||||
import generateSoftwareTableHeaders from "./SoftwareTableConfig";
|
||||
|
|
@ -23,12 +23,14 @@ interface ISoftwareTableProps {
|
|||
isLoading: boolean;
|
||||
software: ISoftware[];
|
||||
deviceUser?: boolean;
|
||||
softwareInventoryEnabled?: boolean;
|
||||
}
|
||||
|
||||
const SoftwareTable = ({
|
||||
isLoading,
|
||||
software,
|
||||
deviceUser,
|
||||
softwareInventoryEnabled,
|
||||
}: ISoftwareTableProps): JSX.Element => {
|
||||
const tableSoftware: ITableSoftware[] = software.map((s) => {
|
||||
return {
|
||||
|
|
@ -76,6 +78,19 @@ const SoftwareTable = ({
|
|||
|
||||
const tableHeaders = generateSoftwareTableHeaders(deviceUser);
|
||||
|
||||
const EmptySoftwareSearch = () => (
|
||||
<EmptyState title="software" reason="empty-search" />
|
||||
);
|
||||
|
||||
if (!softwareInventoryEnabled) {
|
||||
return (
|
||||
<div className="section section--software">
|
||||
<p className="section__header">Software</p>
|
||||
<EmptyState title="software" reason="disabled" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="section section--software">
|
||||
<p className="section__header">Software</p>
|
||||
|
|
@ -101,7 +116,7 @@ const SoftwareTable = ({
|
|||
}
|
||||
onQueryChange={onQueryChange}
|
||||
resultsTitle={"software items"}
|
||||
emptyComponent={EmptySoftware}
|
||||
emptyComponent={EmptySoftwareSearch}
|
||||
showMarkAllPages={false}
|
||||
isAllPagesSelected={false}
|
||||
searchable
|
||||
|
|
@ -113,15 +128,7 @@ const SoftwareTable = ({
|
|||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="results">
|
||||
<p className="results__header">
|
||||
No installed software detected on this host.
|
||||
</p>
|
||||
<p className="results__data">
|
||||
Expecting to see software? Try again in a few seconds as the system
|
||||
catches up.
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState title="software" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
const baseClass = "empty-users";
|
||||
|
||||
const EmptyUsers = (): JSX.Element => {
|
||||
return (
|
||||
<div className={`${baseClass}`}>
|
||||
<div className={`${baseClass}__inner`}>
|
||||
<div className={`${baseClass}__empty-filter-results`}>
|
||||
<h1>No users matched your search criteria.</h1>
|
||||
<p>Try a different search.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyUsers;
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from "./EmptyUsers";
|
||||
|
|
@ -4,7 +4,7 @@ import { IHostUser } from "interfaces/host_users";
|
|||
import TableContainer from "components/TableContainer";
|
||||
|
||||
import generateUsersTableHeaders from "./UsersTable/UsersTableConfig";
|
||||
import EmptyUsers from "./EmptyUsers";
|
||||
import EmptyState from "../EmptyState";
|
||||
|
||||
interface ISearchQueryData {
|
||||
searchQuery: string;
|
||||
|
|
@ -19,6 +19,7 @@ interface IUsersProps {
|
|||
usersState: { username: string }[];
|
||||
isLoading: boolean;
|
||||
onUsersTableSearchChange: (queryData: ISearchQueryData) => void;
|
||||
hostUsersEnabled?: boolean;
|
||||
}
|
||||
|
||||
const Users = ({
|
||||
|
|
@ -26,34 +27,19 @@ const Users = ({
|
|||
usersState,
|
||||
isLoading,
|
||||
onUsersTableSearchChange,
|
||||
hostUsersEnabled,
|
||||
}: IUsersProps): JSX.Element => {
|
||||
const tableHeaders = generateUsersTableHeaders();
|
||||
|
||||
if (users) {
|
||||
const EmptyUserSearch = () => (
|
||||
<EmptyState title="users" reason="empty-search" />
|
||||
);
|
||||
|
||||
if (!hostUsersEnabled) {
|
||||
return (
|
||||
<div className="section section--users">
|
||||
<p className="section__header">Users</p>
|
||||
{users.length === 0 ? (
|
||||
<p className="results__data">No users were detected on this host.</p>
|
||||
) : (
|
||||
<TableContainer
|
||||
columns={tableHeaders}
|
||||
data={usersState}
|
||||
isLoading={isLoading}
|
||||
defaultSortHeader={"username"}
|
||||
defaultSortDirection={"asc"}
|
||||
inputPlaceHolder={"Search users by username"}
|
||||
onQueryChange={onUsersTableSearchChange}
|
||||
resultsTitle={"users"}
|
||||
emptyComponent={EmptyUsers}
|
||||
showMarkAllPages={false}
|
||||
isAllPagesSelected={false}
|
||||
searchable
|
||||
wideSearch
|
||||
filteredCount={usersState.length}
|
||||
isClientSidePagination
|
||||
/>
|
||||
)}
|
||||
<EmptyState title="users" reason="disabled" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -61,7 +47,27 @@ const Users = ({
|
|||
return (
|
||||
<div className="section section--users">
|
||||
<p className="section__header">Users</p>
|
||||
<p className="results__data">No users were detected on this host.</p>
|
||||
{users?.length ? (
|
||||
<TableContainer
|
||||
columns={tableHeaders}
|
||||
data={usersState}
|
||||
isLoading={isLoading}
|
||||
defaultSortHeader={"username"}
|
||||
defaultSortDirection={"asc"}
|
||||
inputPlaceHolder={"Search users by username"}
|
||||
onQueryChange={onUsersTableSearchChange}
|
||||
resultsTitle={"users"}
|
||||
emptyComponent={EmptyUserSearch}
|
||||
showMarkAllPages={false}
|
||||
isAllPagesSelected={false}
|
||||
searchable
|
||||
wideSearch
|
||||
filteredCount={usersState.length}
|
||||
isClientSidePagination
|
||||
/>
|
||||
) : (
|
||||
<EmptyState title="users" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue