From 2000b1b2633abd4324b5445e9d55c001701f177c Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Mon, 11 Oct 2021 13:27:14 -0400 Subject: [PATCH] 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 --- changes/issue-2336-filtered-host-count | 1 + cypress/integration/premium/observer.spec.ts | 11 ++- .../premium/team_maintainer_observer.spec.ts | 10 ++- .../TableContainer/TableContainer.tsx | 8 +- .../TableContainer/TableContainerUtils.ts | 9 ++- frontend/fleet/endpoints.ts | 1 + .../hosts/ManageHostsPage/ManageHostsPage.tsx | 77 +++++++++++++++--- frontend/services/entities/host_count.ts | 79 +++++++++++++++++++ 8 files changed, 172 insertions(+), 24 deletions(-) create mode 100644 changes/issue-2336-filtered-host-count create mode 100644 frontend/services/entities/host_count.ts diff --git a/changes/issue-2336-filtered-host-count b/changes/issue-2336-filtered-host-count new file mode 100644 index 000000000..9f84d0395 --- /dev/null +++ b/changes/issue-2336-filtered-host-count @@ -0,0 +1 @@ +* Manage host page displays total host count with filters applied. diff --git a/cypress/integration/premium/observer.spec.ts b/cypress/integration/premium/observer.spec.ts index 19ca76767..cc65ef0c1 100644 --- a/cypress/integration/premium/observer.spec.ts +++ b/cypress/integration/premium/observer.spec.ts @@ -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"); diff --git a/cypress/integration/premium/team_maintainer_observer.spec.ts b/cypress/integration/premium/team_maintainer_observer.spec.ts index fc2d5e027..689efd6c4 100644 --- a/cypress/integration/premium/team_maintainer_observer.spec.ts +++ b/cypress/integration/premium/team_maintainer_observer.spec.ts @@ -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"); diff --git a/frontend/components/TableContainer/TableContainer.tsx b/frontend/components/TableContainer/TableContainer.tsx index 84d9861d0..20d69e0ef 100644 --- a/frontend/components/TableContainer/TableContainer.tsx +++ b/frontend/components/TableContainer/TableContainer.tsx @@ -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 (
{wideSearch && searchable && ( @@ -218,7 +219,8 @@ const TableContainer = ({ resultsTitle, pageIndex, pageSize, - getCustomCount ? getCustomCount(data) : data.length + displayCount, + filteredCount )} {resultsHtml}

diff --git a/frontend/components/TableContainer/TableContainerUtils.ts b/frontend/components/TableContainer/TableContainerUtils.ts index 7090b0adb..6f291c4a2 100644 --- a/frontend/components/TableContainer/TableContainerUtils.ts +++ b/frontend/components/TableContainer/TableContainerUtils.ts @@ -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}`; }; diff --git a/frontend/fleet/endpoints.ts b/frontend/fleet/endpoints.ts index 5484cbfb1..949af7ad2 100644 --- a/frontend/fleet/endpoints.ts +++ b/frontend/fleet/endpoints.ts @@ -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", diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index 2d6885a0c..61a7b2955 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -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(); const [isHostsLoading, setIsHostsLoading] = useState(false); const [hasHostErrors, setHasHostErrors] = useState(false); + const [filteredHostCount, setFilteredHostCount] = useState(); + const [isHostCountLoading, setIsHostCountLoading] = useState(false); + const [hasHostCountErrors, setHasHostCountErrors] = useState(false); const [sortBy, setSortBy] = useState(initialSortBy); const [policy, setPolicy] = useState(); const [tableQueryData, setTableQueryData] = useState(); @@ -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 ; } @@ -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} /> ); }; diff --git a/frontend/services/entities/host_count.ts b/frontend/services/entities/host_count.ts new file mode 100644 index 000000000..219d1676c --- /dev/null +++ b/frontend/services/entities/host_count.ts @@ -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); + }, +};