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);
+ },
+};