Fleet UI: Server side filtering for global, team, and inherited policies (#13479)

This commit is contained in:
RachelElysia 2023-08-31 11:23:57 -04:00 committed by GitHub
parent 7d0a85bd0a
commit 8a796ff5bd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 373 additions and 96 deletions

View file

@ -22,6 +22,10 @@ export interface IStoredPolicyResponse {
policy: IPolicy;
}
export interface IPoliciesCountResponse {
count: number;
}
export interface IPolicy {
id: number;
name: string;

View file

@ -307,7 +307,7 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
teamId: teamIdForApi,
},
],
({ queryKey }) => softwareAPI.count(queryKey[0]),
({ queryKey }) => softwareAPI.getCount(queryKey[0]),
{
enabled: isRouteOk && !software?.software,
keepPreviousData: true,

View file

@ -2,7 +2,7 @@ import React, { useCallback, useContext, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { InjectedRouter } from "react-router/lib/Router";
import PATHS from "router/paths";
import { noop, isEmpty } from "lodash";
import { noop, isEqual } from "lodash";
import { getNextLocationPath } from "utilities/helpers";
@ -17,12 +17,19 @@ import {
IPolicyStats,
ILoadAllPoliciesResponse,
ILoadTeamPoliciesResponse,
IPoliciesCountResponse,
} from "interfaces/policy";
import { ITeamConfig } from "interfaces/team";
import configAPI from "services/entities/config";
import globalPoliciesAPI from "services/entities/global_policies";
import teamPoliciesAPI from "services/entities/team_policies";
import globalPoliciesAPI, {
IPoliciesCountQueryKey,
IPoliciesQueryKey,
} from "services/entities/global_policies";
import teamPoliciesAPI, {
ITeamPoliciesCountQueryKey,
ITeamPoliciesQueryKey,
} from "services/entities/team_policies";
import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
import { ITableQueryData } from "components/TableContainer/TableContainer";
@ -60,6 +67,10 @@ interface IManagePoliciesPageProps {
};
}
const DEFAULT_SORT_DIRECTION = "asc";
const DEFAULT_PAGE_SIZE = 20;
const DEFAULT_SORT_COLUMN = "name";
const baseClass = "manage-policies-page";
const ManagePolicyPage = ({
@ -128,31 +139,41 @@ const ManagePolicyPage = ({
// Functions to avoid race conditions
const initialSearchQuery = (() => queryParams.query ?? "")();
const initialSortHeader = (() =>
(queryParams?.order_key as "name" | "failing_host_count") ?? "name")();
(queryParams?.order_key as "name" | "failing_host_count") ??
DEFAULT_SORT_COLUMN)();
const initialSortDirection = (() =>
(queryParams?.order_direction as "asc" | "desc") ?? "asc")();
(queryParams?.order_direction as "asc" | "desc") ??
DEFAULT_SORT_DIRECTION)();
const initialPage = (() =>
queryParams && queryParams.page ? parseInt(queryParams?.page, 10) : 0)();
const initialShowInheritedTable = (() =>
queryParams && queryParams.inherited_table === "true")();
const initialInheritedSortHeader = (() =>
(queryParams?.inherited_order_key as "name" | "failing_host_count") ??
"name")();
DEFAULT_SORT_COLUMN)();
const initialInheritedSortDirection = (() =>
(queryParams?.inherited_order_direction as "asc" | "desc") ?? "asc")();
(queryParams?.inherited_order_direction as "asc" | "desc") ??
DEFAULT_SORT_DIRECTION)();
const initialInheritedPage = (() =>
queryParams && queryParams.inherited_page
? parseInt(queryParams?.inherited_page, 10)
: 0)();
const page = initialPage;
const showInheritedTable = initialShowInheritedTable;
const inheritedPage = initialInheritedPage;
const searchQuery = initialSearchQuery;
// Needs update on location change or table state might not match URL
const [searchQuery, setSearchQuery] = useState(initialSearchQuery);
const [page, setPage] = useState(initialPage);
const [inheritedPage, setInheritedPage] = useState(initialInheritedPage);
const [tableQueryData, setTableQueryData] = useState<ITableQueryData>();
const [
inheritedTableQueryData,
setInheritedTableQueryData,
] = useState<ITableQueryData>();
const [sortHeader, setSortHeader] = useState(initialSortHeader);
const [sortDirection, setSortDirection] = useState(initialSortDirection);
const [sortDirection, setSortDirection] = useState<
"asc" | "desc" | undefined
>(initialSortDirection);
const [inheritedSortDirection, setInheritedSortDirection] = useState(
initialInheritedSortDirection
);
@ -168,8 +189,11 @@ const ManagePolicyPage = ({
if (!isRouteOk) {
return;
}
setPage(initialPage);
setSearchQuery(initialSearchQuery);
setSortHeader(initialSortHeader);
setSortDirection(initialSortDirection);
setInheritedPage(initialInheritedPage);
setInheritedSortHeader(initialInheritedSortHeader);
setInheritedSortDirection(initialInheritedSortDirection);
}, [location, isRouteOk]);
@ -195,10 +219,24 @@ const ManagePolicyPage = ({
error: globalPoliciesError,
isFetching: isFetchingGlobalPolicies,
refetch: refetchGlobalPolicies,
} = useQuery<ILoadAllPoliciesResponse, Error, IPolicyStats[]>(
["globalPolicies", teamIdForApi],
() => {
return globalPoliciesAPI.loadAll();
} = useQuery<
ILoadAllPoliciesResponse,
Error,
IPolicyStats[],
IPoliciesQueryKey[]
>(
[
{
scope: "globalPolicies",
page: tableQueryData?.pageIndex,
perPage: DEFAULT_PAGE_SIZE,
query: searchQuery,
orderDirection: sortDirection,
orderKey: sortHeader,
},
],
({ queryKey }) => {
return globalPoliciesAPI.loadAllNew(queryKey[0]);
},
{
enabled: isRouteOk,
@ -207,13 +245,55 @@ const ManagePolicyPage = ({
}
);
const {
data: globalPoliciesCount,
isFetching: isFetchingGlobalCount,
} = useQuery<IPoliciesCountResponse, Error, number, IPoliciesCountQueryKey[]>(
[
{
scope: "policiesCount",
query: isAnyTeamSelected ? "" : searchQuery, // Search query not used for inherited count
},
],
({ queryKey }) => globalPoliciesAPI.getCount(queryKey[0]),
{
enabled: isRouteOk,
keepPreviousData: true,
refetchOnWindowFocus: false,
retry: 1,
select: (data) => data.count,
}
);
const {
error: teamPoliciesError,
isFetching: isFetchingTeamPolicies,
refetch: refetchTeamPolicies,
} = useQuery<ILoadTeamPoliciesResponse, Error, ILoadTeamPoliciesResponse>(
["teamPolicies", teamIdForApi],
() => teamPoliciesAPI.loadAll(teamIdForApi),
} = useQuery<
ILoadTeamPoliciesResponse,
Error,
ILoadTeamPoliciesResponse,
ITeamPoliciesQueryKey[]
>(
[
{
scope: "teamPolicies",
page: tableQueryData?.pageIndex,
perPage: DEFAULT_PAGE_SIZE,
query: searchQuery,
orderDirection: sortDirection,
orderKey: sortHeader,
inheritedPage: inheritedTableQueryData?.pageIndex,
inheritedPerPage: DEFAULT_PAGE_SIZE,
inheritedOrderDirection: inheritedSortDirection,
inheritedOrderKey: inheritedSortHeader,
teamId: teamIdForApi || 0,
},
],
({ queryKey }) => {
return teamPoliciesAPI.loadAllNew(queryKey[0]);
},
{
enabled: isRouteOk && isPremiumTier && !!teamIdForApi,
onSuccess: (data) => {
@ -223,9 +303,32 @@ const ManagePolicyPage = ({
}
);
const canAddOrDeletePolicy =
const { data: teamPoliciesCount, isFetching: isFetchingTeamCount } = useQuery<
IPoliciesCountResponse,
Error,
number,
ITeamPoliciesCountQueryKey[]
>(
[
{
scope: "teamPoliciesCount",
query: searchQuery,
teamId: teamIdForApi || 0, // TODO: Fix number/undefined type
},
],
({ queryKey }) => teamPoliciesAPI.getCount(queryKey[0]),
{
enabled: isRouteOk && !!teamIdForApi,
keepPreviousData: true,
refetchOnWindowFocus: false,
retry: 1,
select: (data) => data.count,
}
);
const canAddOrDeletePolicy: boolean =
isGlobalAdmin || isGlobalMaintainer || isTeamMaintainer || isTeamAdmin;
const canManageAutomations = isGlobalAdmin || isTeamAdmin;
const canManageAutomations: boolean = isGlobalAdmin || isTeamAdmin;
const {
data: config,
@ -281,6 +384,14 @@ const ManagePolicyPage = ({
// Inherited table uses the same onQueryChange function but routes to different URL params
const onQueryChange = useCallback(
async (newTableQuery: ITableQueryData) => {
if (!isRouteOk || isEqual(newTableQuery, tableQueryData)) {
return;
}
newTableQuery.editingInheritedTable
? setInheritedTableQueryData({ ...newTableQuery })
: setTableQueryData({ ...newTableQuery });
const {
pageIndex: newPageIndex,
searchQuery: newSearchQuery,
@ -359,48 +470,6 @@ const ManagePolicyPage = ({
] // Other dependencies can cause infinite re-renders as URL is source of truth
);
const onClientSidePaginationChange = useCallback(
(pageIndex: number) => {
const locationPath = getNextLocationPath({
pathPrefix: PATHS.MANAGE_POLICIES,
queryParams: {
...queryParams,
page: pageIndex,
query: searchQuery,
order_direction: sortDirection,
order_key: sortHeader,
inherited_order_direction: inheritedSortDirection,
inherited_order_key: inheritedSortHeader,
inherited_page: inheritedPage,
},
});
router?.replace(locationPath);
},
[searchQuery, queryParams, sortHeader, sortDirection] // Dependencies required for correct variable state
);
const onClientSideInheritedPaginationChange = useCallback(
(pageIndex: number) => {
const locationPath = getNextLocationPath({
pathPrefix: PATHS.MANAGE_POLICIES,
queryParams: {
...queryParams,
inherited_table: "true",
inherited_page: pageIndex,
query: searchQuery,
page,
order_direction: sortDirection,
order_key: sortHeader,
inherited_order_direction: inheritedSortDirection,
inherited_order_key: inheritedSortHeader,
},
});
router?.replace(locationPath);
},
[queryParams, inheritedSortHeader, inheritedSortDirection] // Dependencies required for correct variable state
);
const toggleManageAutomationsModal = () =>
setShowManageAutomationsModal(!showManageAutomationsModal);
@ -541,6 +610,16 @@ const ManagePolicyPage = ({
}
}
const renderPoliciesCount = (count?: number) => {
return (
<div className={`${baseClass}__count`}>
{count !== undefined && (
<span>{`${count} polic${count === 1 ? "y" : "ies"}`}</span>
)}
</div>
);
};
return !isRouteOk || (isPremiumTier && !userTeams) ? (
<Spinner />
) : (
@ -628,6 +707,9 @@ const ManagePolicyPage = ({
canAddOrDeletePolicy={canAddOrDeletePolicy}
currentTeam={currentTeamSummary}
currentAutomatedPolicies={currentAutomatedPolicies}
renderPoliciesCount={() =>
!isFetchingTeamCount && renderPoliciesCount(teamPoliciesCount)
}
isPremiumTier={isPremiumTier}
isSandboxMode={isSandboxMode}
searchQuery={searchQuery}
@ -653,7 +735,11 @@ const ManagePolicyPage = ({
currentAutomatedPolicies={currentAutomatedPolicies}
isPremiumTier={isPremiumTier}
isSandboxMode={isSandboxMode}
onClientSidePaginationChange={onClientSidePaginationChange}
// onClientSidePaginationChange={onClientSidePaginationChange}
renderPoliciesCount={() =>
!isFetchingGlobalCount &&
renderPoliciesCount(globalPoliciesCount)
}
searchQuery={searchQuery}
sortHeader={sortHeader}
sortDirection={sortDirection}
@ -662,25 +748,27 @@ const ManagePolicyPage = ({
/>
))}
</div>
{showInheritedPoliciesButton && globalPolicies && (
<RevealButton
isShowing={showInheritedTable}
className={baseClass}
hideText={inheritedPoliciesButtonText(
showInheritedTable,
globalPolicies.length
)}
showText={inheritedPoliciesButtonText(
showInheritedTable,
globalPolicies.length
)}
caretPosition={"before"}
tooltipHtml={
'"All teams" policies are checked <br/> for this teams hosts.'
}
onClick={toggleShowInheritedPolicies}
/>
)}
{showInheritedPoliciesButton &&
globalPolicies &&
globalPoliciesCount && (
<RevealButton
isShowing={showInheritedTable}
className={baseClass}
hideText={inheritedPoliciesButtonText(
showInheritedTable,
globalPoliciesCount
)}
showText={inheritedPoliciesButtonText(
showInheritedTable,
globalPoliciesCount
)}
caretPosition={"before"}
tooltipHtml={
'"All teams" policies are checked <br/> for this teams hosts.'
}
onClick={toggleShowInheritedPolicies}
/>
)}
{showInheritedPoliciesButton && showInheritedTable && (
<div className={`${baseClass}__inherited-policies-table`}>
{globalPoliciesError && <TableDataError />}
@ -696,8 +784,11 @@ const ManagePolicyPage = ({
tableType="inheritedPolicies"
currentTeam={currentTeamSummary}
searchQuery=""
onClientSidePaginationChange={
onClientSideInheritedPaginationChange
// onClientSidePaginationChange={
// onClientSideInheritedPaginationChange
// }
renderPoliciesCount={() =>
renderPoliciesCount(teamPoliciesCount)
}
sortHeader={inheritedSortHeader}
sortDirection={inheritedSortDirection}

View file

@ -21,6 +21,7 @@ describe("Policies table", () => {
searchQuery=""
page={0}
onQueryChange={noop}
renderPoliciesCount={noop}
/>
);
@ -47,6 +48,7 @@ describe("Policies table", () => {
searchQuery=""
page={0}
onQueryChange={noop}
renderPoliciesCount={noop}
/>
);

View file

@ -35,7 +35,8 @@ interface IPoliciesTableProps {
currentAutomatedPolicies?: number[];
isPremiumTier?: boolean;
isSandboxMode?: boolean;
onClientSidePaginationChange?: (pageIndex: number) => void;
// onClientSidePaginationChange?: (pageIndex: number) => void;
renderPoliciesCount: any; // TODO: typing
onQueryChange: (newTableQuery: ITableQueryData) => void;
searchQuery: string;
sortHeader?: "name" | "failing_host_count";
@ -55,7 +56,8 @@ const PoliciesTable = ({
isPremiumTier,
isSandboxMode,
onQueryChange,
onClientSidePaginationChange,
// onClientSidePaginationChange,
renderPoliciesCount,
searchQuery,
sortHeader,
sortDirection,
@ -154,7 +156,7 @@ const PoliciesTable = ({
currentAutomatedPolicies,
config?.update_interval.osquery_policy
)}
filters={{ global: searchQuery }}
// filters={{ global: searchQuery }}
isLoading={isLoading}
defaultSortHeader={sortHeader || DEFAULT_SORT_HEADER}
defaultSortDirection={sortDirection || DEFAULT_SORT_DIRECTION}
@ -179,10 +181,11 @@ const PoliciesTable = ({
})
}
disableCount={tableType === "inheritedPolicies"}
isClientSidePagination
onClientSidePaginationChange={onClientSidePaginationChange}
isClientSideFilter
searchQueryColumn="name"
renderCount={renderPoliciesCount}
// isClientSidePagination
// onClientSidePaginationChange={onClientSidePaginationChange}
// isClientSideFilter
// searchQueryColumn="name"
onQueryChange={onTableQueryChange}
inputPlaceHolder="Search by name"
searchable={searchable}

View file

@ -327,7 +327,7 @@ const ManageSoftwarePage = ({
teamId: teamIdForApi,
},
],
({ queryKey }) => softwareAPI.count(queryKey[0]),
({ queryKey }) => softwareAPI.getCount(queryKey[0]),
{
enabled: isRouteOk && isSoftwareConfigLoaded,
keepPreviousData: true,

View file

@ -1,7 +1,45 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { snakeCase, reduce } from "lodash";
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import { IPolicyFormData, ILoadAllPoliciesResponse } from "interfaces/policy";
import {
IPolicyFormData,
ILoadAllPoliciesResponse,
IPoliciesCountResponse,
} from "interfaces/policy";
import { buildQueryStringFromParams, QueryParams } from "utilities/url";
interface IPoliciesApiParams {
page?: number;
perPage?: number;
orderKey?: string;
orderDirection?: "asc" | "desc";
query?: string;
}
export interface IPoliciesQueryKey extends IPoliciesApiParams {
scope: "globalPolicies";
}
export interface IPoliciesCountQueryKey
extends Pick<IPoliciesApiParams, "query"> {
scope: "policiesCount";
}
const ORDER_KEY = "name";
const ORDER_DIRECTION = "asc";
const convertParamsToSnakeCase = (params: IPoliciesApiParams) => {
return reduce<typeof params, QueryParams>(
params,
(result, val, key) => {
result[snakeCase(key)] = val;
return result;
},
{}
);
};
export default {
// TODO: How does the frontend need to support legacy policies?
@ -33,4 +71,40 @@ export default {
return sendRequest("GET", GLOBAL_POLICIES);
},
loadAllNew: async ({
page,
perPage,
orderKey = ORDER_KEY,
orderDirection: orderDir = ORDER_DIRECTION,
query,
}: IPoliciesApiParams): Promise<ILoadAllPoliciesResponse> => {
const { GLOBAL_POLICIES } = endpoints;
const queryParams = {
page,
perPage,
orderKey,
orderDirection: orderDir,
query,
};
const snakeCaseParams = convertParamsToSnakeCase(queryParams);
const queryString = buildQueryStringFromParams(snakeCaseParams);
const path = `${GLOBAL_POLICIES}?${queryString}`;
return sendRequest("GET", path);
},
getCount: async ({
query,
}: Pick<IPoliciesApiParams, "query">): Promise<IPoliciesCountResponse> => {
const { GLOBAL_POLICIES } = endpoints;
const path = `${GLOBAL_POLICIES}/count`;
const queryParams = {
query,
};
const snakeCaseParams = convertParamsToSnakeCase(queryParams);
const queryString = buildQueryStringFromParams(snakeCaseParams);
return sendRequest("GET", path.concat(`?${queryString}`));
},
};

View file

@ -74,7 +74,7 @@ export default {
}
},
count: async ({
getCount: async ({
query,
teamId,
vulnerable,

View file

@ -1,8 +1,59 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { snakeCase, reduce } from "lodash";
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import { ILoadTeamPoliciesResponse, IPolicyFormData } from "interfaces/policy";
import {
ILoadTeamPoliciesResponse,
IPolicyFormData,
IPoliciesCountResponse,
} from "interfaces/policy";
import { API_NO_TEAM_ID } from "interfaces/team";
import { buildQueryStringFromParams, QueryParams } from "utilities/url";
interface IPoliciesApiQueryParams {
page?: number;
perPage?: number;
orderKey?: string;
orderDirection?: "asc" | "desc";
query?: string;
inheritedPage?: number;
inheritedPerPage?: number;
inheritedOrderKey?: string;
inheritedOrderDirection?: "asc" | "desc";
}
export interface IPoliciesApiParams extends IPoliciesApiQueryParams {
teamId: number;
}
export interface ITeamPoliciesQueryKey extends IPoliciesApiParams {
scope: "teamPolicies";
}
export interface ITeamPoliciesCountQueryKey
extends Pick<IPoliciesApiParams, "query" | "teamId"> {
scope: "teamPoliciesCount";
}
interface IPoliciesCountApiParams {
teamId: number;
query?: string;
}
const ORDER_KEY = "name";
const ORDER_DIRECTION = "asc";
const convertParamsToSnakeCase = (params: IPoliciesApiQueryParams) => {
return reduce<typeof params, QueryParams>(
params,
(result, val, key) => {
result[snakeCase(key)] = val;
return result;
},
{}
);
};
export default {
create: (data: IPolicyFormData) => {
@ -77,4 +128,56 @@ export default {
return sendRequest("GET", path);
},
loadAllNew: async ({
teamId,
page,
perPage,
orderKey = ORDER_KEY,
orderDirection: orderDir = ORDER_DIRECTION,
query,
inheritedPage,
inheritedPerPage,
inheritedOrderKey = ORDER_KEY,
inheritedOrderDirection: inheritedOrderDir = ORDER_DIRECTION,
}: IPoliciesApiParams): Promise<ILoadTeamPoliciesResponse> => {
const { TEAMS } = endpoints;
const queryParams = {
page,
perPage,
orderKey,
orderDirection: orderDir,
query,
inheritedPage,
inheritedPerPage,
inheritedOrderKey,
inheritedOrderDirection: inheritedOrderDir,
};
const snakeCaseParams = convertParamsToSnakeCase(queryParams);
const queryString = buildQueryStringFromParams(snakeCaseParams);
const path = `${TEAMS}/${teamId}/policies?${queryString}`;
if (!teamId) {
throw new Error("Invalid team id");
}
return sendRequest("GET", path);
},
getCount: async ({
query,
teamId,
}: Pick<
IPoliciesCountApiParams,
"query" | "teamId"
>): Promise<IPoliciesCountResponse> => {
const { TEAM_POLICIES } = endpoints;
const path = `${TEAM_POLICIES(teamId)}/count`;
const queryParams = {
query,
};
const snakeCaseParams = convertParamsToSnakeCase(queryParams);
const queryString = buildQueryStringFromParams(snakeCaseParams);
return sendRequest("GET", path.concat(`?${queryString}`));
},
};