Manage Host Page: Custom host count based on filters, labels, etc (#2377)

* Host count includes label id parameter
* Remove page and pageSize from hostCountAPI call
* Only retrieve hostCount on filter change
This commit is contained in:
RachelElysia 2021-10-11 13:27:14 -04:00 committed by GitHub
parent 89f4234819
commit 2000b1b263
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 172 additions and 24 deletions

View File

@ -0,0 +1 @@
* Manage host page displays total host count with filters applied.

View File

@ -18,7 +18,7 @@ describe("Premium tier - Observer user", () => {
cy.visit("/hosts/manage");
// Ensure page is loaded
cy.wait(5000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.wait(3000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.contains("All hosts");
cy.get("thead").within(() => {
@ -62,7 +62,11 @@ describe("Premium tier - Observer user", () => {
cy.visit("/hosts/manage");
cy.wait(3000); // eslint-disable-line cypress/no-unnecessary-waiting
// Ensure the page is loaded and teams are visible
cy.findByText("Hosts").should("exist");
cy.contains(".table-container .data-table__table th", "Team").should(
"be.visible"
);
// Nav restrictions
cy.findByText(/settings/i).should("not.exist");
@ -74,11 +78,6 @@ describe("Premium tier - Observer user", () => {
cy.visit("/schedule/manage");
cy.findByText(/you do not have permissions/i).should("exist");
cy.visit("/hosts/manage");
cy.contains(".table-container .data-table__table th", "Team").should(
"be.visible"
);
// On the Profile page, they should…
// See Global in the Team section and Observer in the Role section
cy.visit("/profile");

View File

@ -9,7 +9,8 @@ describe(
cy.login();
cy.seedPremium();
cy.seedQueries();
cy.addDockerHost();
cy.addDockerHost("apples");
cy.addDockerHost("oranges");
cy.logout();
});
afterEach(() => {
@ -20,7 +21,7 @@ describe(
cy.login("marco@organization.com", "user123#");
cy.visit("/hosts/manage");
// Ensure page is loaded
// Ensure page is loaded and teams are visible
cy.contains("Hosts");
// On the Hosts page, they should…
@ -198,6 +199,11 @@ describe(
cy.findByRole("button", { name: /schedule a query/i }).click();
// TODO: Write e2e test for team maintainer to schedule a query
cy.visit("/hosts/manage");
cy.contains(".table-container .data-table__table th", "Team").should(
"be.visible"
);
// On the Profile page, they should…
// See 2 Teams in the Team section and Various in the Role section
cy.visit("/profile");

View File

@ -59,7 +59,7 @@ interface ITableContainerProps {
secondarySelectActions?: IActionButtonProps[]; // TODO create table actions interface
customControl?: () => JSX.Element;
onSelectSingleRow?: (value: Row) => void;
getCustomCount?: (data: any) => number;
filteredCount?: number;
}
const baseClass = "table-container";
@ -103,7 +103,7 @@ const TableContainer = ({
secondarySelectActions,
customControl,
onSelectSingleRow,
getCustomCount,
filteredCount,
}: ITableContainerProps): JSX.Element => {
const [searchQuery, setSearchQuery] = useState("");
const [sortHeader, setSortHeader] = useState(defaultSortHeader || "");
@ -198,6 +198,7 @@ const TableContainer = ({
prevSearchQuery,
]);
const displayCount = filteredCount || data.length;
return (
<div className={wrapperClasses}>
{wideSearch && searchable && (
@ -218,7 +219,8 @@ const TableContainer = ({
resultsTitle,
pageIndex,
pageSize,
getCustomCount ? getCustomCount(data) : data.length
displayCount,
filteredCount
)}
{resultsHtml}
</p>

View File

@ -4,7 +4,8 @@ const generateResultsCountText = (
name: string = DEFAULT_RESULTS_NAME,
pageIndex: number,
pageSize: number,
resultsCount: number
resultsCount: number,
filteredCount?: number
): string => {
if (resultsCount === 0) return `No ${name}`;
// If there is 1 result and the last 3 letters in the result
@ -26,8 +27,10 @@ const generateResultsCountText = (
return `${resultsCount} ${name.slice(0, -1)}`;
}
if (pageSize === resultsCount) return `${pageSize}+ ${name}`;
if (pageIndex !== 0 && resultsCount <= pageSize)
// If there is no filtered count, return pageSize+ name
if (pageSize === resultsCount && !filteredCount)
return `${pageSize}+ ${name}`;
if (pageIndex !== 0 && resultsCount <= pageSize && !filteredCount)
return `${pageSize}+ ${name}`;
return `${resultsCount} ${name}`;
};

View File

@ -17,6 +17,7 @@ export default {
ACTIVITIES: "/v1/fleet/activities",
GLOBAL_SCHEDULE: "/v1/fleet/global/schedule",
HOSTS: "/v1/fleet/hosts",
HOSTS_COUNT: "/v1/fleet/hosts/count",
HOSTS_DELETE: "/v1/fleet/hosts/delete",
HOSTS_TRANSFER: "/v1/fleet/hosts/transfer",
HOSTS_TRANSFER_BY_FILTER: "/v1/fleet/hosts/transfer/filter",

View File

@ -14,6 +14,9 @@ import hostsAPI, {
IHostLoadOptions,
ISortOption,
} from "services/entities/hosts";
import hostCountAPI, {
IHostCountLoadOptions,
} from "services/entities/host_count";
import PATHS from "router/paths";
import { AppContext } from "context/app";
@ -175,6 +178,9 @@ const ManageHostsPage = ({
const [hosts, setHosts] = useState<IHost[]>();
const [isHostsLoading, setIsHostsLoading] = useState<boolean>(false);
const [hasHostErrors, setHasHostErrors] = useState<boolean>(false);
const [filteredHostCount, setFilteredHostCount] = useState<number>();
const [isHostCountLoading, setIsHostCountLoading] = useState<boolean>(false);
const [hasHostCountErrors, setHasHostCountErrors] = useState<boolean>(false);
const [sortBy, setSortBy] = useState<ISortOption[]>(initialSortBy);
const [policy, setPolicy] = useState<IPolicy>();
const [tableQueryData, setTableQueryData] = useState<ITableQueryProps>();
@ -250,14 +256,6 @@ const ManageHostsPage = ({
}
);
// const toggleEnrollSecretModal = () => {
// setShowEnrollSecretModal(!showEnrollSecretModal);
// };
// const toggleAddHostModal = () => {
// setShowAddHostModal(!showAddHostModal);
// };
const toggleDeleteLabelModal = () => {
setShowDeleteLabelModal(!showDeleteLabelModal);
};
@ -310,6 +308,30 @@ const ManageHostsPage = ({
}
};
const retrieveHostCount = async (options: IHostCountLoadOptions = {}) => {
setIsHostCountLoading(true);
options = {
...options,
teamId: getValidatedTeamId(
teams || [],
options.teamId as number,
currentUser,
isOnGlobalTeam as boolean
),
};
try {
const { count: returnedHostCount } = await hostCountAPI.load(options);
setFilteredHostCount(returnedHostCount);
} catch (error) {
console.error(error);
setHasHostCountErrors(true);
} finally {
setIsHostCountLoading(false);
}
};
// triggered every time the route is changed
// which means every filter click and text search
useDeepEffect(() => {
@ -345,6 +367,40 @@ const ManageHostsPage = ({
retrieveHosts(options);
}, [location, tableQueryData, labels]);
useDeepEffect(() => {
// set the team object in context
const teamId = parseInt(queryParams?.team_id, 10) || 0;
const selectedTeam = find(teams, ["id", teamId]);
setCurrentTeam(selectedTeam);
// set selected label
const slugToFind =
(selectedFilters.length > 0 &&
selectedFilters.find((f) => f.includes(LABEL_SLUG_PREFIX))) ||
selectedFilters[0];
const selected = find(labels, ["slug", slugToFind]) as ILabel;
setSelectedLabel(selected);
// get the hosts
const options: IHostLoadOptions = {
selectedLabels: selectedFilters,
globalFilter: searchQuery,
sortBy,
teamId: selectedTeam?.id,
policyId,
policyResponse,
};
retrieveHostCount(options);
}, [
queryParams.team_id,
searchQuery,
policyId,
policyResponse,
selectedFilters,
]);
const handleLabelChange = ({ slug }: ILabel) => {
if (!slug) {
console.error("Slug was missing. This should not happen.");
@ -1085,7 +1141,7 @@ const ManageHostsPage = ({
return null;
}
if (hasHostErrors) {
if (hasHostErrors || hasHostCountErrors) {
return <TableDataError />;
}
@ -1114,7 +1170,7 @@ const ManageHostsPage = ({
currentTeam
)}
data={hosts}
isLoading={isHostsLoading}
isLoading={isHostsLoading || isHostCountLoading}
manualSortBy
defaultSortHeader={(sortBy[0] && sortBy[0].key) || DEFAULT_SORT_HEADER}
defaultSortDirection={
@ -1139,6 +1195,7 @@ const ManageHostsPage = ({
toggleAllPagesSelected={toggleAllMatchingHosts}
searchable
customControl={renderStatusDropdown}
filteredCount={filteredHostCount}
/>
);
};

View File

@ -0,0 +1,79 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import sendRequest from "services";
import endpoints from "fleet/endpoints";
import { IHost } from "interfaces/host";
export interface ISortOption {
key: string;
direction: string;
}
export interface IHostCountLoadOptions {
page?: number;
perPage?: number;
sortBy?: ISortOption[];
status?: string;
globalFilter?: string;
teamId?: number;
policyId?: number;
policyResponse?: string;
selectedLabels?: string[];
}
export default {
// hostCount.load share similar variables and parameters with hosts.loadAll
load: (options: IHostCountLoadOptions | undefined) => {
const { HOSTS_COUNT } = endpoints;
const sortBy = options?.sortBy || [];
const globalFilter = options?.globalFilter || "";
const teamId = options?.teamId || null;
const policyId = options?.policyId || null;
const policyResponse = options?.policyResponse || null;
const selectedLabels = options?.selectedLabels || [];
let orderKeyParam = "";
let orderDirection = "";
if (sortBy.length !== 0) {
const sortItem = sortBy[0];
orderKeyParam += `order_key=${sortItem.key}`;
orderDirection = `&order_direction=${sortItem.direction}`;
}
let searchQuery = "";
if (globalFilter !== "") {
searchQuery = `&query=${globalFilter}`;
}
const labelPrefix = "labels/";
// Handle multiple filters
const label = selectedLabels.find((f) => f.includes(labelPrefix));
const status = selectedLabels.find((f) => !f.includes(labelPrefix));
const isValidStatus =
status === "new" ||
status === "online" ||
status === "offline" ||
status === "mia";
let path = `${HOSTS_COUNT}?${orderKeyParam}${orderDirection}${searchQuery}`;
if (status && isValidStatus) {
path += `&status=${status}`;
}
if (label) {
path += `&label_id=${parseInt(label.substr(labelPrefix.length), 10)}`;
}
if (teamId) {
path += `&team_id=${teamId}`;
}
if (!label && policyId) {
path += `&policy_id=${policyId}`;
path += `&policy_response=${policyResponse || "passing"}`; // TODO confirm whether there should be a default if there is an id but no response specified
}
return sendRequest("GET", path);
},
};