diff --git a/changes/21855-paginate-queries b/changes/21855-paginate-queries new file mode 100644 index 000000000000..a54dfb43c8c7 --- /dev/null +++ b/changes/21855-paginate-queries @@ -0,0 +1,5 @@ +- Fleshed out server response from `queries` endpoint to include `count` and `meta` pagination information. +- Updated UI queries page to filter, sort, paginate, etc. via query params in call to server. +- Updated platform filtering on queries page to refer to targeted platforms instead of compatible + platforms +- Updated queries API to support above targeted platform filtering diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index 41b89ea6ba71..bba40091748b 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -1250,7 +1250,7 @@ func TestGetQueries(t *testing.T) { } return nil, ¬FoundError{} } - ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, error) { + ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { if opt.TeamID == nil { //nolint:gocritic // ignore ifElseChain return []*fleet.Query{ { @@ -1282,7 +1282,7 @@ func TestGetQueries(t *testing.T) { Saved: true, // ListQueries always returns the saved ones. ObserverCanRun: true, }, - }, nil + }, 3, nil, nil } else if *opt.TeamID == 1 { return []*fleet.Query{ { @@ -1299,11 +1299,11 @@ func TestGetQueries(t *testing.T) { TeamID: ptr.Uint(1), ObserverCanRun: true, }, - }, nil + }, 1, nil, nil } else if *opt.TeamID == 2 { - return []*fleet.Query{}, nil + return []*fleet.Query{}, 0, nil, nil } - return nil, errors.New("invalid team ID") + return nil, 0, nil, errors.New("invalid team ID") } expectedGlobal := `+--------+-------------+-----------+-----------+--------------------------------+ @@ -1563,7 +1563,7 @@ func TestGetQueriesAsObserver(t *testing.T) { } } - ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, error) { + ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { return []*fleet.Query{ { ID: 42, @@ -1586,7 +1586,7 @@ func TestGetQueriesAsObserver(t *testing.T) { Query: "select 3;", ObserverCanRun: false, }, - }, nil + }, 3, nil, nil } for _, tc := range []struct { @@ -1794,7 +1794,7 @@ spec: GlobalRole: nil, Teams: []fleet.UserTeam{{Role: fleet.RoleObserver}}, }) - ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, error) { + ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { return []*fleet.Query{ { ID: 42, @@ -1810,12 +1810,12 @@ spec: Query: "select 2;", ObserverCanRun: false, }, - }, nil + }, 2, nil, nil } assert.Equal(t, "", runAppForTest(t, []string{"get", "queries"})) // No filtering is performed if all are observer_can_run. - ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, error) { + ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { return []*fleet.Query{ { ID: 42, @@ -1831,7 +1831,7 @@ spec: Query: "select 2;", ObserverCanRun: true, }, - }, nil + }, 2, nil, nil } expected = `+--------+-------------+-----------+-----------+----------------------------+ | NAME | DESCRIPTION | QUERY | TEAM | SCHEDULE | diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index ad11066b2aa5..cfb97a6de5e3 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -69,7 +69,9 @@ func TestGitOpsBasicGlobalFree(t *testing.T) { return nil } ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil } - ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil } + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil + } // Mock appConfig savedAppConfig := &fleet.AppConfig{} @@ -224,7 +226,9 @@ func TestGitOpsBasicGlobalPremium(t *testing.T) { return nil } ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil } - ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil } + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil + } // Mock appConfig savedAppConfig := &fleet.AppConfig{} @@ -364,7 +368,9 @@ func TestGitOpsBasicTeam(t *testing.T) { ) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) { return nil, nil, nil } - ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil } + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil + } team := &fleet.Team{ ID: 1, CreatedAt: time.Now(), @@ -600,8 +606,8 @@ func TestGitOpsFullGlobal(t *testing.T) { query.ID = 1 query.Name = "Query to delete" queryDeleted := false - ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { - return []*fleet.Query{&query}, nil + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { + return []*fleet.Query{&query}, 1, nil, nil } ds.DeleteQueriesFunc = func(ctx context.Context, ids []uint) (uint, error) { queryDeleted = true @@ -861,8 +867,8 @@ func TestGitOpsFullTeam(t *testing.T) { query.TeamID = ptr.Uint(teamID) query.Name = "Query to delete" queryDeleted := false - ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { - return []*fleet.Query{&query}, nil + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { + return []*fleet.Query{&query}, 1, nil, nil } ds.DeleteQueriesFunc = func(ctx context.Context, ids []uint) (uint, error) { queryDeleted = true @@ -1154,7 +1160,9 @@ func TestGitOpsBasicGlobalAndTeam(t *testing.T) { } return nil, nil } - ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil } + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil + } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, ) error { @@ -1485,7 +1493,9 @@ func TestGitOpsBasicGlobalAndNoTeam(t *testing.T) { ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { return nil, nil } - ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil } + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil + } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, ) error { @@ -2441,7 +2451,9 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, } return nil, nil } - ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil } + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil + } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, ) error { diff --git a/cmd/fleetctl/query_test.go b/cmd/fleetctl/query_test.go index 8e5441b58ad5..6553b41aafe8 100644 --- a/cmd/fleetctl/query_test.go +++ b/cmd/fleetctl/query_test.go @@ -60,11 +60,11 @@ func TestSavedLiveQuery(t *testing.T) { ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{}, nil } - ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, error) { + ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { if opt.MatchQuery == queryName { - return []*fleet.Query{&query}, nil + return []*fleet.Query{&query}, 1, nil, nil } - return []*fleet.Query{}, nil + return []*fleet.Query{}, 0, nil, nil } ds.NewDistributedQueryCampaignFunc = func(ctx context.Context, camp *fleet.DistributedQueryCampaign) (*fleet.DistributedQueryCampaign, error) { camp.ID = 321 diff --git a/cmd/fleetctl/session_test.go b/cmd/fleetctl/session_test.go index 62796bcc25b2..8d06769c0fdf 100644 --- a/cmd/fleetctl/session_test.go +++ b/cmd/fleetctl/session_test.go @@ -14,8 +14,8 @@ import ( func TestEarlySessionCheck(t *testing.T) { _, ds := runServerWithMockedDS(t) - ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, error) { - return nil, nil + ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil } ds.SessionByKeyFunc = func(ctx context.Context, key string) (*fleet.Session, error) { return nil, errors.New("invalid session") diff --git a/cmd/fleetctl/upgrade_packs_test.go b/cmd/fleetctl/upgrade_packs_test.go index c30ae6e2c3b6..79c96c989178 100644 --- a/cmd/fleetctl/upgrade_packs_test.go +++ b/cmd/fleetctl/upgrade_packs_test.go @@ -247,8 +247,8 @@ func TestFleetctlUpgradePacks_EmptyPacks(t *testing.T) { return fleet.TargetMetrics{}, nil } - ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, error) { - return nil, nil + ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil } tempDir := t.TempDir() @@ -314,12 +314,12 @@ func TestFleetctlUpgradePacks_NonEmpty(t *testing.T) { return fleet.TargetMetrics{}, nil } - ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, error) { + ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { return []*fleet.Query{ {Name: "q1", Query: "select 1"}, {Name: "q2", Query: "select 2"}, {Name: "q3", Query: "select 3"}, - }, nil + }, 3, nil, nil } const expected = ` diff --git a/frontend/components/PlatformCompatibility/PlatformCompatibility.tsx b/frontend/components/PlatformCompatibility/PlatformCompatibility.tsx index 700726d37ecb..59d93e71c81d 100644 --- a/frontend/components/PlatformCompatibility/PlatformCompatibility.tsx +++ b/frontend/components/PlatformCompatibility/PlatformCompatibility.tsx @@ -88,8 +88,13 @@ const PlatformCompatibility = ({ tipContent={ <> Estimated compatibility based on the - tables used in the query. Querying - iPhones & iPads is not supported. + tables used in the query. + + + Only live queries are supported on ChromeOS. + + + Querying iPhones & iPads is not supported. > } > diff --git a/frontend/hooks/usePlatformCompatibility.tsx b/frontend/hooks/usePlatformCompatibility.tsx index ebb4ec1ee4e1..88a8d1b2fa07 100644 --- a/frontend/hooks/usePlatformCompatibility.tsx +++ b/frontend/hooks/usePlatformCompatibility.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useState } from "react"; import { useDebouncedCallback } from "use-debounce"; -import { QueryablePlatform, SUPPORTED_PLATFORMS } from "interfaces/platform"; +import { QueryablePlatform, QUERYABLE_PLATFORMS } from "interfaces/platform"; import { checkPlatformCompatibility } from "utilities/sql_tools"; import PlatformCompatibility from "components/PlatformCompatibility"; @@ -37,7 +37,7 @@ const usePlatformCompatibility = (): IPlatformCompatibility => { ); const getCompatiblePlatforms = useCallback( - () => SUPPORTED_PLATFORMS.filter((p) => compatiblePlatforms?.includes(p)), + () => QUERYABLE_PLATFORMS.filter((p) => compatiblePlatforms?.includes(p)), [compatiblePlatforms] ); diff --git a/frontend/hooks/usePlatformSelector.tsx b/frontend/hooks/usePlatformSelector.tsx index b1fb384dd98f..4d7ad70f3a73 100644 --- a/frontend/hooks/usePlatformSelector.tsx +++ b/frontend/hooks/usePlatformSelector.tsx @@ -3,7 +3,7 @@ import { forEach } from "lodash"; import { SelectedPlatformString, - SUPPORTED_PLATFORMS, + QUERYABLE_PLATFORMS, QueryablePlatform, } from "interfaces/platform"; @@ -48,7 +48,7 @@ const usePlatformSelector = ( }; const getSelectedPlatforms = useCallback(() => { - return SUPPORTED_PLATFORMS.filter((p) => checksByPlatform[p]); + return QUERYABLE_PLATFORMS.filter((p) => checksByPlatform[p]); }, [checksByPlatform]); const isAnyPlatformSelected = Object.values(checksByPlatform).includes(true); diff --git a/frontend/interfaces/platform.ts b/frontend/interfaces/platform.ts index 1f33a1639fdf..d96fbbef3934 100644 --- a/frontend/interfaces/platform.ts +++ b/frontend/interfaces/platform.ts @@ -22,13 +22,18 @@ export type QueryableDisplayPlatform = Exclude< >; export type QueryablePlatform = Exclude; -export const SUPPORTED_PLATFORMS: QueryablePlatform[] = [ +export const QUERYABLE_PLATFORMS: QueryablePlatform[] = [ "darwin", "windows", "linux", "chrome", ]; +export const isQueryablePlatform = ( + platform: string | undefined +): platform is QueryablePlatform => + QUERYABLE_PLATFORMS.includes(platform as QueryablePlatform); + // TODO - add "iOS" and "iPadOS" once we support them export const VULN_SUPPORTED_PLATFORMS: Platform[] = ["darwin", "windows"]; diff --git a/frontend/interfaces/query.ts b/frontend/interfaces/query.ts index 9485f2da9a5c..e3d0a41d43af 100644 --- a/frontend/interfaces/query.ts +++ b/frontend/interfaces/query.ts @@ -14,10 +14,6 @@ export interface IStoredQueryResponse { query: ISchedulableQuery; } -export interface IFleetQueriesResponse { - queries: ISchedulableQuery[]; -} - export interface IQuery { created_at: string; updated_at: string; diff --git a/frontend/interfaces/schedulable_query.ts b/frontend/interfaces/schedulable_query.ts index fb0fcac74bb5..ff3698ca57e9 100644 --- a/frontend/interfaces/schedulable_query.ts +++ b/frontend/interfaces/schedulable_query.ts @@ -3,7 +3,11 @@ import PropTypes from "prop-types"; import { IFormField } from "./form_field"; import { IPack } from "./pack"; -import { SelectedPlatformString, QueryablePlatform } from "./platform"; +import { + SelectedPlatformString, + QueryablePlatform, + SelectedPlatform, +} from "./platform"; // Query itself export interface ISchedulableQuery { @@ -15,7 +19,7 @@ export interface ISchedulableQuery { query: string; team_id: number | null; interval: number; - platform: SelectedPlatformString; // Might more accurately be called `platforms_to_query` – comma-sepparated string of platforms to query, default all platforms if ommitted + platform: SelectedPlatformString; // Might more accurately be called `platforms_to_query` or `targeted_platforms` – comma-sepparated string of platforms to query, default all platforms if ommitted min_osquery_version: string; automations_enabled: boolean; logging: QueryLoggingOption; @@ -32,7 +36,7 @@ export interface ISchedulableQuery { export interface IEnhancedQuery extends ISchedulableQuery { performance: string; - platforms: QueryablePlatform[]; + targetedPlatforms: QueryablePlatform[]; } export interface ISchedulableQueryStats { user_time_p50?: number | null; @@ -67,7 +71,14 @@ export interface IListQueriesResponse { export interface IQueryKeyQueriesLoadAll { scope: "queries"; - teamId: number | undefined; + teamId?: number; + page?: number; + perPage?: number; + query?: string; + orderDirection?: "asc" | "desc"; + orderKey?: string; + mergeInherited?: boolean; + targetedPlatform?: SelectedPlatform; } // Create a new query /** POST /api/v1/fleet/queries */ diff --git a/frontend/pages/DashboardPage/DashboardPage.tsx b/frontend/pages/DashboardPage/DashboardPage.tsx index 5c72c33fc990..056acc965c17 100644 --- a/frontend/pages/DashboardPage/DashboardPage.tsx +++ b/frontend/pages/DashboardPage/DashboardPage.tsx @@ -460,7 +460,7 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { setShowAddHostsModal(!showAddHostsModal); }; - // NOTE: this is called once on the initial rendering. The initial render of + // This is called once on the initial rendering. The initial render of // the TableContainer child component will call this handler. const onSoftwareQueryChange = async ({ pageIndex: newPageIndex, diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 8e46f71971b7..5aa5100dcb6b 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -214,19 +214,24 @@ const HostDetailsPage = ({ >("past"); const [activityPage, setActivityPage] = useState(0); + // Optimization TODO: move this call into the SelectQuery modal, since queries are only used if that modal is opened const { data: fleetQueries, error: fleetQueriesError } = useQuery< IListQueriesResponse, Error, ISchedulableQuery[], IQueryKeyQueriesLoadAll[] - >([{ scope: "queries", teamId: undefined }], () => queryAPI.loadAll(), { - enabled: !!hostIdFromURL, - refetchOnMount: false, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - retry: false, - select: (data: IListQueriesResponse) => data.queries, - }); + >( + [{ scope: "queries", teamId: undefined }], + ({ queryKey }) => queryAPI.loadAll(queryKey[0]), + { + enabled: !!hostIdFromURL, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + retry: false, + select: (data: IListQueriesResponse) => data.queries, + } + ); const { data: teams } = useQuery( "teams", diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx index 8eaac7e65278..7c5290779606 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftware.tsx @@ -250,14 +250,14 @@ const HostSoftware = ({ return isMyDevicePage ? generateDeviceSoftwareTableConfig() : generateHostSoftwareTableConfig({ - router, - softwareIdActionPending, userHasSWWritePermission, hostScriptsEnabled, - onSelectAction, - teamId: hostTeamId, hostCanWriteSoftware, hostMDMEnrolled, + softwareIdActionPending, + router, + teamId: hostTeamId, + onSelectAction, }); }, [ isMyDevicePage, diff --git a/frontend/pages/packs/EditPackPage/EditPackPage.tsx b/frontend/pages/packs/EditPackPage/EditPackPage.tsx index 3359e74af3f8..47718c9009b8 100644 --- a/frontend/pages/packs/EditPackPage/EditPackPage.tsx +++ b/frontend/pages/packs/EditPackPage/EditPackPage.tsx @@ -2,20 +2,25 @@ import React, { useState, useCallback, useContext } from "react"; import { useQuery } from "react-query"; import { InjectedRouter, Params } from "react-router/lib/Router"; +import { AppContext } from "context/app"; +import { NotificationContext } from "context/notification"; + import { IPack, IStoredPackResponse } from "interfaces/pack"; -import { IQuery, IFleetQueriesResponse } from "interfaces/query"; +import { IQuery } from "interfaces/query"; import { IPackQueryFormData, IScheduledQuery, IStoredScheduledQueriesResponse, } from "interfaces/scheduled_query"; import { ITarget, ITargetsAPIResponse } from "interfaces/target"; -import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; - +import { + IQueryKeyQueriesLoadAll, + ISchedulableQuery, +} from "interfaces/schedulable_query"; import { getErrorReason } from "interfaces/errors"; + import packsAPI from "services/entities/packs"; -import queriesAPI from "services/entities/queries"; +import queriesAPI, { IQueriesResponse } from "services/entities/queries"; import scheduledQueriesAPI from "services/entities/scheduled_queries"; import PATHS from "router/paths"; @@ -50,13 +55,18 @@ const EditPacksPage = ({ const packId: number = parseInt(paramsPackId, 10); - const { data: fleetQueries } = useQuery< - IFleetQueriesResponse, + const { data: queries } = useQuery< + IQueriesResponse, Error, - IQuery[] - >(["fleet queries"], () => queriesAPI.loadAll(), { - select: (data: IFleetQueriesResponse) => data.queries, - }); + ISchedulableQuery[], + IQueryKeyQueriesLoadAll[] + >( + [{ scope: "queries", teamId: undefined }], + ({ queryKey }) => queriesAPI.loadAll(queryKey[0]), + { + select: (data) => data.queries, + } + ); const { data: storedPack } = useQuery( ["stored pack"], @@ -244,17 +254,17 @@ const EditPacksPage = ({ isUpdatingPack={isUpdatingPack} /> )} - {showPackQueryEditorModal && fleetQueries && ( + {showPackQueryEditorModal && queries && ( )} - {showRemovePackQueryModal && fleetQueries && ( + {showRemovePackQueryModal && queries && ( (); + const [ + tableQueryDataForApi, + setTableQueryDataForApi, + ] = useState(); const [sortHeader, setSortHeader] = useState(initialSortHeader); const [sortDirection, setSortDirection] = useState< "asc" | "desc" | undefined @@ -225,7 +228,7 @@ const ManagePolicyPage = ({ [ { scope: "globalPolicies", - page: tableQueryData?.pageIndex, + page: tableQueryDataForApi?.pageIndex, perPage: DEFAULT_PAGE_SIZE, query: searchQuery, orderDirection: sortDirection, @@ -281,7 +284,7 @@ const ManagePolicyPage = ({ [ { scope: "teamPolicies", - page: tableQueryData?.pageIndex, + page: tableQueryDataForApi?.pageIndex, perPage: DEFAULT_PAGE_SIZE, query: searchQuery, orderDirection: sortDirection, @@ -390,7 +393,7 @@ const ManagePolicyPage = ({ // NOTE: used to reset page number to 0 when modifying filters const handleResetPageIndex = () => { - setTableQueryData( + setTableQueryDataForApi( (prevState) => ({ ...prevState, @@ -412,11 +415,11 @@ const ManagePolicyPage = ({ // TODO: Look into useDebounceCallback with dependencies const onQueryChange = useCallback( async (newTableQuery: ITableQueryData) => { - if (!isRouteOk || isEqual(newTableQuery, tableQueryData)) { + if (!isRouteOk || isEqual(newTableQuery, tableQueryDataForApi)) { return; } - setTableQueryData({ ...newTableQuery }); + setTableQueryDataForApi({ ...newTableQuery }); const { pageIndex: newPageIndex, @@ -451,11 +454,11 @@ const ManagePolicyPage = ({ queryParams: { ...queryParams, ...newQueryParams }, }); - router?.replace(locationPath); + router?.push(locationPath); }, [ isRouteOk, - tableQueryData, + tableQueryDataForApi, sortDirection, sortHeader, searchQuery, diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx index 808aadf80c6b..6db439a07392 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTable.tsx @@ -55,12 +55,6 @@ const PoliciesTable = ({ }: IPoliciesTableProps): JSX.Element => { const { config } = useContext(AppContext); - const onTableQueryChange = (newTableQuery: ITableQueryData) => { - onQueryChange({ - ...newTableQuery, - }); - }; - const emptyState = () => { const emptyPolicies: IEmptyTableProps = { graphicName: "empty-policies", @@ -135,7 +129,7 @@ const PoliciesTable = ({ }) } renderCount={renderPoliciesCount} - onQueryChange={onTableQueryChange} + onQueryChange={onQueryChange} inputPlaceHolder="Search by name" searchable={searchable} resetPageIndex={resetPageIndex} diff --git a/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx b/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx index 34550c98728e..2c988659814f 100644 --- a/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx +++ b/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx @@ -14,7 +14,11 @@ import { QueryContext } from "context/query"; import { TableContext } from "context/table"; import { NotificationContext } from "context/notification"; import { getPerformanceImpactDescription } from "utilities/helpers"; -import { QueryablePlatform, SelectedPlatform } from "interfaces/platform"; +import { + isQueryablePlatform, + QueryablePlatform, + SelectedPlatform, +} from "interfaces/platform"; import { IEnhancedQuery, IQueryKeyQueriesLoadAll, @@ -22,10 +26,9 @@ import { } from "interfaces/schedulable_query"; import { DEFAULT_TARGETS_BY_TYPE } from "interfaces/target"; import { API_ALL_TEAMS_ID } from "interfaces/team"; -import queriesAPI from "services/entities/queries"; +import queriesAPI, { IQueriesResponse } from "services/entities/queries"; import PATHS from "router/paths"; import { DEFAULT_QUERY } from "utilities/constants"; -import { checkPlatformCompatibility } from "utilities/sql_tools"; import Button from "components/buttons/Button"; import Spinner from "components/Spinner"; import TableDataError from "components/DataError"; @@ -37,13 +40,16 @@ import DeleteQueryModal from "./components/DeleteQueryModal"; import ManageQueryAutomationsModal from "./components/ManageQueryAutomationsModal/ManageQueryAutomationsModal"; import PreviewDataModal from "./components/PreviewDataModal/PreviewDataModal"; +const DEFAULT_PAGE_SIZE = 20; + const baseClass = "manage-queries-page"; interface IManageQueriesPageProps { router: InjectedRouter; // v3 location: { pathname: string; query: { - platform?: SelectedPlatform; + // note that the URL value "darwin" will correspond to the request query param "macos" + platform?: SelectedPlatform; // which targeted platform to filter queries by page?: string; query?: string; order_key?: string; @@ -54,10 +60,9 @@ interface IManageQueriesPageProps { }; } -const getPlatforms = (queryString: string): QueryablePlatform[] => { - const { platforms } = checkPlatformCompatibility(queryString); - - return platforms ?? []; +const getTargetedPlatforms = (platformString: string): QueryablePlatform[] => { + const platforms = platformString.split(","); + return platforms.filter(isQueryablePlatform); }; export const enhanceQuery = (q: ISchedulableQuery): IEnhancedQuery => { @@ -66,7 +71,7 @@ export const enhanceQuery = (q: ISchedulableQuery): IEnhancedQuery => { performance: getPerformanceImpactDescription( pick(q.stats, ["user_time_p50", "system_time_p50", "total_executions"]) ), - platforms: getPlatforms(q.query), + targetedPlatforms: getTargetedPlatforms(q.platform), }; }; @@ -74,7 +79,6 @@ const ManageQueriesPage = ({ router, location, }: IManageQueriesPageProps): JSX.Element => { - const queryParams = location.query; const { isGlobalAdmin, isGlobalMaintainer, @@ -118,46 +122,54 @@ const ManageQueriesPage = ({ const [showPreviewDataModal, setShowPreviewDataModal] = useState(false); const [isUpdatingQueries, setIsUpdatingQueries] = useState(false); const [isUpdatingAutomations, setIsUpdatingAutomations] = useState(false); - const [queriesAvailableToAutomate, setQueriesAvailableToAutomate] = useState< - IEnhancedQuery[] | [] - >([]); + + const curPageFromURL = location.query.page + ? parseInt(location.query.page, 10) + : 0; const { - data: enhancedQueries, + data: queriesResponse, error: queriesError, isFetching: isFetchingQueries, + isLoading: isLoadingQueries, refetch: refetchQueries, } = useQuery< - IEnhancedQuery[], + IQueriesResponse, Error, - IEnhancedQuery[], + IQueriesResponse, IQueryKeyQueriesLoadAll[] >( - [{ scope: "queries", teamId: teamIdForApi }], - ({ queryKey: [{ teamId }] }) => - queriesAPI - .loadAll(teamId, teamId !== API_ALL_TEAMS_ID) - .then(({ queries }) => queries.map(enhanceQuery)), + [ + { + scope: "queries", + teamId: teamIdForApi, + page: curPageFromURL, + perPage: DEFAULT_PAGE_SIZE, + // a search match query, not a Fleet Query + query: location.query.query, + orderDirection: location.query.order_direction, + orderKey: location.query.order_key, + mergeInherited: teamIdForApi !== API_ALL_TEAMS_ID, + targetedPlatform: location.query.platform, + }, + ], + ({ queryKey }) => queriesAPI.loadAll(queryKey[0]), { refetchOnWindowFocus: false, enabled: isRouteOk, staleTime: 5000, - onSuccess: (data) => { - if (data) { - const enhancedAllQueries = data.map(enhanceQuery); - - const allQueriesAvailableToAutomate = teamIdForApi - ? enhancedAllQueries.filter( - (query: IEnhancedQuery) => query.team_id === currentTeamId - ) - : enhancedAllQueries; - - setQueriesAvailableToAutomate(allQueriesAvailableToAutomate); - } - }, } ); + const enhancedQueries = queriesResponse?.queries.map(enhanceQuery); + + const queriesAvailableToAutomate = + (teamIdForApi + ? enhancedQueries?.filter( + (query: IEnhancedQuery) => query.team_id === currentTeamId + ) + : enhancedQueries) ?? []; + const onlyInheritedQueries = useMemo(() => { if (teamIdForApi === API_ALL_TEAMS_ID) { // global scope @@ -166,11 +178,9 @@ const ManageQueriesPage = ({ return !enhancedQueries?.some((query) => query.team_id === teamIdForApi); }, [teamIdForApi, enhancedQueries]); - const automatedQueryIds = useMemo(() => { - return queriesAvailableToAutomate - .filter((query) => query.automations_enabled) - .map((query) => query.id); - }, [queriesAvailableToAutomate]); + const automatedQueryIds = queriesAvailableToAutomate + .filter((query) => query.automations_enabled) + .map((query) => query.id); useEffect(() => { const path = location.pathname + location.search; @@ -263,7 +273,7 @@ const ManageQueriesPage = ({ }; const renderQueriesTable = () => { - if (isFetchingQueries) { + if (isLoadingQueries) { return ; } if (queriesError) { @@ -271,7 +281,9 @@ const ManageQueriesPage = ({ } return ( ); @@ -327,7 +340,12 @@ const ManageQueriesPage = ({ setIsUpdatingAutomations(false); } }, - [refetchQueries, automatedQueryIds, toggleManageAutomationsModal] + [ + automatedQueryIds, + renderFlash, + refetchQueries, + toggleManageAutomationsModal, + ] ); const renderModals = () => { @@ -367,6 +385,13 @@ const ManageQueriesPage = ({ isTeamMaintainer || isObserverPlus; // isObserverPlus checks global and selected team + const hideQueryActions = + // there are no filters and no returned queries, indicating there are no global/team queries at all + !(!!location.query.query || !!location.query.platform) && + !queriesResponse?.count && + // the user has permission + (!isOnlyObserver || isObserverPlus || isAnyTeamObserverPlus); + return ( @@ -376,7 +401,8 @@ const ManageQueriesPage = ({ {renderHeader()} - {!!enhancedQueries?.length && ( + + {!hideQueryActions && ( {(isGlobalAdmin || isTeamAdmin) && !onlyInheritedQueries && ( { it("Renders the page-wide empty state when no queries are present", () => { const testData: IQueriesTableProps[] = [ { - queriesList: [], + queries: [], + totalQueriesCount: 0, + hasNextResults: false, onlyInheritedQueries: false, isLoading: false, onDeleteQueryClick: jest.fn(), @@ -169,7 +171,9 @@ describe("QueriesTable", () => { it("Renders inherited global queries and team queries when viewing a team, then renders the 'no-matching' empty state when a search string is entered that matches no queries", async () => { const testData: IQueriesTableProps[] = [ { - queriesList: [...testGlobalQueries, ...testTeamQueries], + queries: [...testGlobalQueries, ...testTeamQueries], + totalQueriesCount: 4, + hasNextResults: false, onlyInheritedQueries: false, isLoading: false, onDeleteQueryClick: jest.fn(), @@ -228,7 +232,9 @@ describe("QueriesTable", () => { const { user } = render( { const { user } = render( { render( { const { container, user } = render( void; @@ -38,7 +33,7 @@ export interface IQueriesTableProps { isAnyTeamObserverPlus: boolean; router?: InjectedRouter; queryParams?: { - platform?: SelectedPlatform; + platform?: string; // which targeted platform to filter queries by page?: string; query?: string; order_key?: string; @@ -50,44 +45,36 @@ export interface IQueriesTableProps { const DEFAULT_SORT_DIRECTION = "asc"; const DEFAULT_SORT_HEADER = "name"; -const DEFAULT_PAGE_SIZE = 20; -const DEFAULT_PLATFORM = "all"; +// all platforms +const DEFAULT_PLATFORM: SelectedPlatform = "all"; const PLATFORM_FILTER_OPTIONS = [ { disabled: false, label: "All platforms", value: "all", - helpText: "All queries.", }, { disabled: false, label: "macOS", value: "darwin", - helpText: "Queries that are compatible with macOS operating systems.", }, { disabled: false, label: "Windows", value: "windows", - helpText: "Queries that are compatible with Windows operating systems.", }, { disabled: false, label: "Linux", value: "linux", - helpText: "Queries that are compatible with Linux operating systems.", - }, - { - disabled: false, - label: "ChromeOS", - value: "chrome", - helpText: "Queries that are compatible with Chromebooks.", }, ]; const QueriesTable = ({ - queriesList, + queries, + totalQueriesCount, + hasNextResults, onlyInheritedQueries, isLoading, onDeleteQueryClick, @@ -101,38 +88,6 @@ const QueriesTable = ({ }: IQueriesTableProps): JSX.Element | null => { const { currentUser } = useContext(AppContext); - // Client side filtering bugs fixed with bypassing TableContainer filters - // queriesState tracks search filter and compatible platform filter - // to correctly show filtered queries and filtered count - // isQueryStateLoading prevents flashing of unfiltered count during clientside filtering - const [queriesState, setQueriesState] = useState([]); - const [isQueriesStateLoading, setIsQueriesStateLoading] = useState(true); - - useEffect(() => { - setIsQueriesStateLoading(true); - if (queriesList) { - setQueriesState( - queriesList.filter((query) => { - const filterSearchQuery = queryParams?.query - ? query.name - .toLowerCase() - .includes(queryParams?.query.toLowerCase()) - : true; - const compatiblePlatforms = - checkPlatformCompatibility(query.query).platforms || []; - - const filterCompatiblePlatform = - queryParams?.platform && queryParams?.platform !== "all" - ? compatiblePlatforms.includes(queryParams?.platform) - : true; - - return filterSearchQuery && filterCompatiblePlatform; - }) || [] - ); - } - setIsQueriesStateLoading(false); - }, [queriesList, queryParams]); - // Functions to avoid race conditions const initialSearchQuery = (() => queryParams?.query ?? "")(); const initialSortHeader = (() => @@ -141,23 +96,27 @@ const QueriesTable = ({ const initialSortDirection = (() => (queryParams?.order_direction as "asc" | "desc") ?? DEFAULT_SORT_DIRECTION)(); - const initialPlatform = (() => - (queryParams?.platform as "all" | "windows" | "linux" | "darwin") ?? - DEFAULT_PLATFORM)(); const initialPage = (() => queryParams && queryParams.page ? parseInt(queryParams?.page, 10) : 0)(); // Source of truth is state held within TableContainer. That state is initialized using URL // params, then subsequent updates to that state are pushed to the URL. + // TODO - remove extraneous defintions around these values const searchQuery = initialSearchQuery; - const platform = initialPlatform; const page = initialPage; const sortDirection = initialSortDirection; const sortHeader = initialSortHeader; + const targetedPlatformParam = queryParams?.platform; + const curTargetedPlatformFilter: SelectedPlatform = isQueryablePlatform( + targetedPlatformParam + ) + ? targetedPlatformParam + : DEFAULT_PLATFORM; + // TODO: Look into useDebounceCallback with dependencies const onQueryChange = useCallback( - async (newTableQuery: ITableQueryData) => { + (newTableQuery: ITableQueryData) => { const { pageIndex: newPageIndex, searchQuery: newSearchQuery, @@ -165,13 +124,13 @@ const QueriesTable = ({ sortHeader: newSortHeader, } = newTableQuery; - // Rebuild queryParams to dispatch new browser location to react-router - const newQueryParams: { [key: string]: string | number | undefined } = {}; - - // Updates URL params + const newQueryParams: Record = {}; newQueryParams.order_key = newSortHeader; newQueryParams.order_direction = newSortDirection; - newQueryParams.platform = platform; // must set from URL + newQueryParams.platform = + curTargetedPlatformFilter === "all" + ? undefined + : curTargetedPlatformFilter; newQueryParams.page = newPageIndex; newQueryParams.query = newSearchQuery; // Reset page number to 0 for new filters @@ -182,46 +141,36 @@ const QueriesTable = ({ ) { newQueryParams.page = "0"; } - newQueryParams.team_id = queryParams?.team_id; + const locationPath = getNextLocationPath({ pathPrefix: PATHS.MANAGE_QUERIES, queryParams: { ...queryParams, ...newQueryParams }, }); - router?.replace(locationPath); + router?.push(locationPath); }, - [sortHeader, sortDirection, searchQuery, platform, router, page] - ); - - const onClientSidePaginationChange = useCallback( - (pageIndex: number) => { - const newQueryParams = { - ...queryParams, - page: pageIndex, // update main table index - query: searchQuery, - }; - - const locationPath = getNextLocationPath({ - pathPrefix: PATHS.MANAGE_QUERIES, - queryParams: newQueryParams, - }); - router?.replace(locationPath); - }, - [platform, searchQuery, sortDirection, sortHeader] // Dependencies required for correct variable state + [ + sortHeader, + sortDirection, + searchQuery, + curTargetedPlatformFilter, + router, + page, + ] ); const getEmptyStateParams = useCallback(() => { - const emptyQueries: IEmptyTableProps = { + const emptyParams: IEmptyTableProps = { graphicName: "empty-queries", header: "You don't have any queries", }; - if (searchQuery) { - delete emptyQueries.graphicName; - emptyQueries.header = "No matching queries"; - emptyQueries.info = "No queries match the current filters."; + if (searchQuery || curTargetedPlatformFilter !== "all") { + delete emptyParams.graphicName; + emptyParams.header = "No matching queries"; + emptyParams.info = "No queries match the current filters."; } else if (!isOnlyObserver || isObserverPlus || isAnyTeamObserverPlus) { - emptyQueries.additionalInfo = ( + emptyParams.additionalInfo = ( <> Create a new query, or{" "} > ); - emptyQueries.primaryButton = ( + emptyParams.primaryButton = ( { - const handlePlatformFilterDropdownChange = (platformSelected: string) => { - router?.replace( + const handlePlatformFilterDropdownChange = useCallback( + (selectedTargetedPlatform: string) => { + router?.push( getNextLocationPath({ pathPrefix: PATHS.MANAGE_QUERIES, queryParams: { ...queryParams, page: 0, - platform: platformSelected, + platform: + // separate URL & API 0-values of `platform` (undefined) from dropdown + // 0-value of "all" + selectedTargetedPlatform === "all" + ? undefined + : selectedTargetedPlatform, }, }) ); - }; + }, + [queryParams, router] + ); + const renderPlatformDropdown = useCallback(() => { return ( ); - }, [platform, queryParams, router]); - - const renderQueriesCount = useCallback(() => { - // Fixes flashing incorrect count before clientside filtering - if (isQueriesStateLoading) { - return null; - } - - return ; - }, [queriesState, isQueriesStateLoading]); + }, [curTargetedPlatformFilter, queryParams, router]); const columnConfigs = useMemo( () => @@ -297,7 +245,10 @@ const QueriesTable = ({ [currentUser, currentTeamId, onlyInheritedQueries] ); - const searchable = !(queriesList?.length === 0 && searchQuery === ""); + const searchable = + (totalQueriesCount ?? 0) > 0 || + !!curTargetedPlatformFilter || + !!searchQuery; const emptyComponent = useCallback(() => { const { @@ -318,52 +269,41 @@ const QueriesTable = ({ const trimmedSearchQuery = searchQuery.trim(); - const deleteQueryTableActionButtonProps = useMemo( - () => - ({ - name: "delete query", - buttonText: "Delete", - iconSvg: "trash", - variant: "text-icon", - onActionButtonClick: onDeleteQueryClick, - // this maintains the existing typing, which is not actually correct - // TODO - update this object to actually implement IActionButtonProps - } as IActionButtonProps), - [onDeleteQueryClick] - ); - - return columnConfigs && !isLoading ? ( - - - - ) : ( - <>> + return ( + columnConfigs && ( + + ( + + )} + inputPlaceHolder="Search by name" + onQueryChange={onQueryChange} + searchable={searchable} + customControl={searchable ? renderPlatformDropdown : undefined} + selectedDropdownFilter={curTargetedPlatformFilter} + /> + + ) ); }; diff --git a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx index bb9689dd3f3f..1b7ed1add5de 100644 --- a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx +++ b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx @@ -12,7 +12,11 @@ import { IEnhancedQuery, ISchedulableQuery, } from "interfaces/schedulable_query"; -import { QueryablePlatform } from "interfaces/platform"; +import { + isQueryablePlatform, + QueryablePlatform, + SelectedPlatformString, +} from "interfaces/platform"; import { API_ALL_TEAMS_ID } from "interfaces/team"; import Icon from "components/Icon"; @@ -81,7 +85,7 @@ interface IBoolCellProps extends IRowProps { } interface IPlatformCellProps extends IRowProps { cell: { - value: QueryablePlatform[]; + value: SelectedPlatformString; }; } @@ -181,11 +185,17 @@ const generateTableHeaders = ({ }, { title: "Platform", - Header: "Compatible with", + Header: "Targeted platforms", disableSortBy: true, - accessor: "platforms", + accessor: "platform", Cell: (cellProps: IPlatformCellProps): JSX.Element => { - return ; + const platforms = cellProps.cell.value + .split(",") + .map((s) => s.trim()) + // this casting is necessary because make generate for some reason doesn't recognize the + // type guarding of `isQueryablePlatform` even though the language server in VSCode does + .filter((s) => isQueryablePlatform(s)) as QueryablePlatform[]; + return ; }, }, { diff --git a/frontend/services/entities/global_policies.ts b/frontend/services/entities/global_policies.ts index 340f5b5e4b1f..0cf44c0ca4ba 100644 --- a/frontend/services/entities/global_policies.ts +++ b/frontend/services/entities/global_policies.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { snakeCase, reduce } from "lodash"; import sendRequest from "services"; import endpoints from "utilities/endpoints"; @@ -8,7 +7,10 @@ import { ILoadAllPoliciesResponse, IPoliciesCountResponse, } from "interfaces/policy"; -import { buildQueryStringFromParams, QueryParams } from "utilities/url"; +import { + buildQueryStringFromParams, + convertParamsToSnakeCase, +} from "utilities/url"; interface IPoliciesApiParams { page?: number; @@ -30,17 +32,6 @@ export interface IPoliciesCountQueryKey const ORDER_KEY = "name"; const ORDER_DIRECTION = "asc"; -const convertParamsToSnakeCase = (params: IPoliciesApiParams) => { - return reduce( - params, - (result, val, key) => { - result[snakeCase(key)] = val; - return result; - }, - {} - ); -}; - export default { // TODO: How does the frontend need to support legacy policies? create: (data: IPolicyFormData) => { @@ -71,7 +62,7 @@ export default { return sendRequest("GET", GLOBAL_POLICIES); }, - loadAllNew: async ({ + loadAllNew: ({ page, perPage, orderKey = ORDER_KEY, @@ -94,7 +85,7 @@ export default { return sendRequest("GET", path); }, - getCount: async ({ + getCount: ({ query, }: Pick): Promise => { const { GLOBAL_POLICIES } = endpoints; diff --git a/frontend/services/entities/queries.ts b/frontend/services/entities/queries.ts index 4ff7afc1b1a0..49381ec83a91 100644 --- a/frontend/services/entities/queries.ts +++ b/frontend/services/entities/queries.ts @@ -6,8 +6,37 @@ import { ISelectedTargetsForApi } from "interfaces/target"; import { ICreateQueryRequestBody, IModifyQueryRequestBody, + IQueryKeyQueriesLoadAll, + ISchedulableQuery, } from "interfaces/schedulable_query"; -import { buildQueryStringFromParams } from "utilities/url"; +import { + buildQueryStringFromParams, + convertParamsToSnakeCase, +} from "utilities/url"; +import { SelectedPlatform } from "interfaces/platform"; + +export interface ILoadQueriesParams { + teamId?: number; + page?: number; + perPage?: number; + query?: string; + orderDirection?: "asc" | "desc"; + orderKey?: string; + mergeInherited?: boolean; + targetedPlatform?: SelectedPlatform; +} +export interface IQueryKeyLoadQueries extends ILoadQueriesParams { + scope: "queries"; +} + +export interface IQueriesResponse { + queries: ISchedulableQuery[]; + count: number; + meta: { + has_next_results: boolean; + has_previous_results: boolean; + }; +} export default { create: (createQueryRequestBody: ICreateQueryRequestBody) => { @@ -35,17 +64,41 @@ export default { return sendRequest("GET", path); }, - loadAll: (teamId?: number, mergeInherited = false) => { + loadAll: ({ + teamId, + page, + perPage, + query, + orderDirection, + orderKey, + mergeInherited, + // FE logic uses less ambiguous `targetedPlatform`, while API expects `platform` for alignment + // with other API conventions and database `queries.platform` column + targetedPlatform: platform, + }: IQueryKeyQueriesLoadAll): Promise => { const { QUERIES } = endpoints; - const queryString = buildQueryStringFromParams({ - team_id: teamId, - merge_inherited: mergeInherited || null, + + const snakeCaseParams = convertParamsToSnakeCase({ + teamId, + page, + perPage, + query, + orderDirection, + orderKey, + mergeInherited, + platform, }); - const path = `${QUERIES}`; + + // API expects "macos" instead of "darwin" + if (snakeCaseParams.platform === "darwin") { + snakeCaseParams.platform = "macos"; + } + + const queryString = buildQueryStringFromParams(snakeCaseParams); return sendRequest( "GET", - queryString ? path.concat(`?${queryString}`) : path + queryString ? QUERIES.concat(`?${queryString}`) : QUERIES ); }, run: async ({ diff --git a/frontend/utilities/sql_tools.ts b/frontend/utilities/sql_tools.ts index edf845fc0d79..6fdca47fd6b6 100644 --- a/frontend/utilities/sql_tools.ts +++ b/frontend/utilities/sql_tools.ts @@ -4,7 +4,7 @@ import { intersection, isPlainObject } from "lodash"; import { osqueryTablesAvailable } from "utilities/osquery_tables"; import { MACADMINS_EXTENSION_TABLES, - SUPPORTED_PLATFORMS, + QUERYABLE_PLATFORMS, QueryablePlatform, } from "interfaces/platform"; import { TableSchemaPlatform } from "interfaces/osquery_table"; @@ -59,7 +59,7 @@ const filterCompatiblePlatforms = ( sqlTables: string[] ): QueryablePlatform[] => { if (!sqlTables.length) { - return [...SUPPORTED_PLATFORMS]; // if a query has no tables but is still syntatically valid sql, it is treated as compatible with all platforms + return [...QUERYABLE_PLATFORMS]; // if a query has no tables but is still syntatically valid sql, it is treated as compatible with all platforms } const compatiblePlatforms = intersection( @@ -68,7 +68,7 @@ const filterCompatiblePlatforms = ( ) ); - return SUPPORTED_PLATFORMS.filter((p) => compatiblePlatforms.includes(p)); + return QUERYABLE_PLATFORMS.filter((p) => compatiblePlatforms.includes(p)); }; export const parseSqlTables = ( diff --git a/server/datastore/mysql/queries.go b/server/datastore/mysql/queries.go index 94a4ec501c6e..d3a5e4d7dc38 100644 --- a/server/datastore/mysql/queries.go +++ b/server/datastore/mysql/queries.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" + "golang.org/x/text/unicode/norm" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/go-kit/log/level" @@ -17,6 +19,8 @@ const ( statsLiveQueryType ) +var querySearchColumns = []string{"q.name"} + func (ds *Datastore) ApplyQueries(ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{}) error { if err := ds.applyQueriesInTx(ctx, authorID, queries); err != nil { return ctxerr.Wrap(ctx, err, "apply queries in tx") @@ -468,9 +472,10 @@ func (ds *Datastore) Query(ctx context.Context, id uint) (*fleet.Query, error) { } // ListQueries returns a list of queries with sort order and results limit -// determined by passed in fleet.ListOptions -func (ds *Datastore) ListQueries(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, error) { - sql := ` +// determined by passed in fleet.ListOptions, count of total queries returned without limits, and +// pagination metadata +func (ds *Datastore) ListQueries(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { + getQueriesStmt := ` SELECT q.id, q.team_id, @@ -488,7 +493,6 @@ func (ds *Datastore) ListQueries(ctx context.Context, opt fleet.ListQueryOptions q.discard_data, q.created_at, q.updated_at, - q.discard_data, COALESCE(u.name, '') AS author_name, COALESCE(u.email, '') AS author_email, JSON_EXTRACT(json_value, '$.user_time_p50') as user_time_p50, @@ -523,24 +527,51 @@ func (ds *Datastore) ListQueries(ctx context.Context, opt fleet.ListQueryOptions } } - if opt.MatchQuery != "" { - whereClauses += " AND q.name = ?" - args = append(args, opt.MatchQuery) + if opt.Platform != nil { + qs := fmt.Sprintf("%%%s%%", *opt.Platform) + args = append(args, qs) + whereClauses += ` AND (q.platform LIKE ? OR q.platform = '')` } - sql += whereClauses - sql, args = appendListOptionsWithCursorToSQL(sql, args, &opt.ListOptions) + // normalize the name for full Unicode support (Unicode equivalence). + normMatch := norm.NFC.String(opt.MatchQuery) + whereClauses, args = searchLike(whereClauses, args, normMatch, querySearchColumns...) - results := []*fleet.Query{} - if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, sql, args...); err != nil { - return nil, ctxerr.Wrap(ctx, err, "listing queries") + getQueriesStmt += whereClauses + + // build the count statement before adding pagination constraints + getQueriesCountStmt := fmt.Sprintf("SELECT COUNT(DISTINCT id) FROM (%s) AS s", getQueriesStmt) + + getQueriesStmt, args = appendListOptionsWithCursorToSQL(getQueriesStmt, args, &opt.ListOptions) + + dbReader := ds.reader(ctx) + queries := []*fleet.Query{} + if err := sqlx.SelectContext(ctx, dbReader, &queries, getQueriesStmt, args...); err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "listing queries") } - if err := ds.loadPacksForQueries(ctx, results); err != nil { - return nil, ctxerr.Wrap(ctx, err, "loading packs for queries") + // perform a second query to grab the count + var count int + if err := sqlx.GetContext(ctx, dbReader, &count, getQueriesCountStmt, args...); err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "get queries count") } - return results, nil + if err := ds.loadPacksForQueries(ctx, queries); err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "loading packs for queries") + } + + var meta *fleet.PaginationMetadata + if opt.ListOptions.IncludeMetadata { + meta = &fleet.PaginationMetadata{HasPreviousResults: opt.ListOptions.Page > 0} + // `appendListOptionsWithCursorToSQL` used above to build the query statement will cause this + // discrepancy + if len(queries) > int(opt.ListOptions.PerPage) { //nolint:gosec // dismiss G115 + meta.HasNextResults = true + queries = queries[:len(queries)-1] + } + } + + return queries, count, meta, nil } // loadPacksForQueries loads the user packs (aka 2017 packs) associated with the provided queries. diff --git a/server/datastore/mysql/queries_test.go b/server/datastore/mysql/queries_test.go index 7011c0998e2e..a1315efabb6b 100644 --- a/server/datastore/mysql/queries_test.go +++ b/server/datastore/mysql/queries_test.go @@ -79,9 +79,10 @@ func testQueriesApply(t *testing.T, ds *Datastore) { err := ds.ApplyQueries(context.Background(), zwass.ID, expectedQueries, nil) require.NoError(t, err) - queries, err := ds.ListQueries(context.Background(), fleet.ListQueryOptions{}) + queries, count, _, err := ds.ListQueries(context.Background(), fleet.ListQueryOptions{}) require.NoError(t, err) require.Len(t, queries, len(expectedQueries)) + require.Equal(t, count, len(expectedQueries)) test.QueryElementsMatch(t, expectedQueries, queries) @@ -99,9 +100,10 @@ func testQueriesApply(t *testing.T, ds *Datastore) { err = ds.ApplyQueries(context.Background(), groob.ID, expectedQueries, nil) require.NoError(t, err) - queries, err = ds.ListQueries(context.Background(), fleet.ListQueryOptions{}) + queries, count, _, err = ds.ListQueries(context.Background(), fleet.ListQueryOptions{}) require.NoError(t, err) require.Len(t, queries, len(expectedQueries)) + require.Equal(t, count, len(expectedQueries)) test.QueryElementsMatch(t, expectedQueries, queries) @@ -126,9 +128,10 @@ func testQueriesApply(t *testing.T, ds *Datastore) { err = ds.ApplyQueries(context.Background(), zwass.ID, []*fleet.Query{expectedQueries[2]}, nil) require.NoError(t, err) - queries, err = ds.ListQueries(context.Background(), fleet.ListQueryOptions{}) + queries, count, _, err = ds.ListQueries(context.Background(), fleet.ListQueryOptions{}) require.NoError(t, err) require.Len(t, queries, len(expectedQueries)) + require.Equal(t, count, len(expectedQueries)) test.QueryElementsMatch(t, expectedQueries, queries) @@ -275,9 +278,10 @@ func testQueriesDeleteMany(t *testing.T, ds *Datastore) { q3 := test.NewQuery(t, ds, nil, "q3", "select 1", user.ID, true) q4 := test.NewQuery(t, ds, nil, "q4", "select * from osquery_info", user.ID, true) - queries, err := ds.ListQueries(context.Background(), fleet.ListQueryOptions{}) + queries, count, _, err := ds.ListQueries(context.Background(), fleet.ListQueryOptions{}) require.Nil(t, err) assert.Len(t, queries, 4) + require.Equal(t, count, 4) // Add query stats hostIDs := []uint{10, 20} @@ -312,9 +316,11 @@ func testQueriesDeleteMany(t *testing.T, ds *Datastore) { require.Nil(t, err) assert.Equal(t, uint(2), deleted) - queries, err = ds.ListQueries(context.Background(), fleet.ListQueryOptions{}) + queries, count, _, err = ds.ListQueries(context.Background(), fleet.ListQueryOptions{}) require.Nil(t, err) assert.Len(t, queries, 2) + assert.Equal(t, count, 2) + // Ensure stats were deleted. // The actual delete occurs asynchronously, so we for-loop. statsGone := make(chan bool) @@ -346,17 +352,19 @@ func testQueriesDeleteMany(t *testing.T, ds *Datastore) { require.Nil(t, err) assert.Equal(t, uint(1), deleted) - queries, err = ds.ListQueries(context.Background(), fleet.ListQueryOptions{}) + queries, count, _, err = ds.ListQueries(context.Background(), fleet.ListQueryOptions{}) require.Nil(t, err) assert.Len(t, queries, 1) + assert.Equal(t, count, 1) deleted, err = ds.DeleteQueries(context.Background(), []uint{q2.ID, q4.ID}) require.Nil(t, err) assert.Equal(t, uint(1), deleted) - queries, err = ds.ListQueries(context.Background(), fleet.ListQueryOptions{}) + queries, count, _, err = ds.ListQueries(context.Background(), fleet.ListQueryOptions{}) require.Nil(t, err) assert.Len(t, queries, 0) + assert.Equal(t, count, 0) } func testQueriesSave(t *testing.T, ds *Datastore) { @@ -451,6 +459,19 @@ func testQueriesList(t *testing.T, ds *Datastore) { user := test.NewUser(t, ds, "Zach", "zwass@fleet.co", true) for i := 0; i < 10; i++ { + // populate platform field of first 4 queries + var p string + switch i { + case 0: + p = "darwin" + case 1: + p = "windows" + case 2: + p = "linux" + case 3: + p = "darwin,windows,linux" + } + _, err := ds.NewQuery(context.Background(), &fleet.Query{ Name: fmt.Sprintf("name%02d", i), Query: fmt.Sprintf("query%02d", i), @@ -459,6 +480,7 @@ func testQueriesList(t *testing.T, ds *Datastore) { DiscardData: true, ObserverCanRun: rand.Intn(2) == 0, //nolint:gosec Logging: fleet.LoggingSnapshot, + Platform: p, }) require.Nil(t, err) } @@ -474,12 +496,107 @@ func testQueriesList(t *testing.T, ds *Datastore) { require.NoError(t, err) opts := fleet.ListQueryOptions{} - results, err := ds.ListQueries(context.Background(), opts) + opts.IncludeMetadata = true + + opts.Platform = ptr.String("darwin") + // filtered by platform + results, count, meta, err := ds.ListQueries(context.Background(), opts) + require.NoError(t, err) + require.Equal(t, 8, len(results)) + assert.Equal(t, count, 8) + assert.False(t, meta.HasPreviousResults) + assert.False(t, meta.HasNextResults) + require.Equal(t, "darwin", results[0].Platform) + require.Equal(t, "darwin,windows,linux", results[1].Platform) + + opts.Platform = ptr.String("windows") + results, count, meta, err = ds.ListQueries(context.Background(), opts) + require.NoError(t, err) + require.Equal(t, 8, len(results)) + assert.Equal(t, count, 8) + assert.False(t, meta.HasPreviousResults) + assert.False(t, meta.HasNextResults) + require.Equal(t, "windows", results[0].Platform) + require.Equal(t, "darwin,windows,linux", results[1].Platform) + + opts.Platform = ptr.String("linux") + results, count, meta, err = ds.ListQueries(context.Background(), opts) + require.NoError(t, err) + require.Equal(t, 8, len(results)) + assert.Equal(t, count, 8) + assert.False(t, meta.HasPreviousResults) + assert.False(t, meta.HasNextResults) + require.Equal(t, "linux", results[0].Platform) + require.Equal(t, "darwin,windows,linux", results[1].Platform) + + opts.Platform = ptr.String("lucas") + results, count, meta, err = ds.ListQueries(context.Background(), opts) + require.NoError(t, err) + // only returns queries set to run on all platforms with platform == "" + require.Equal(t, 6, len(results)) + assert.Equal(t, count, 6) + assert.False(t, meta.HasPreviousResults) + assert.False(t, meta.HasNextResults) + + opts.Platform = nil + + // paginated - beginning + opts.PerPage = 3 + opts.Page = 0 + results, count, meta, err = ds.ListQueries(context.Background(), opts) + require.NoError(t, err) + require.Equal(t, 3, len(results)) + require.Equal(t, "Zach", results[0].AuthorName) + require.Equal(t, "zwass@fleet.co", results[0].AuthorEmail) + require.True(t, results[0].DiscardData) + assert.Equal(t, count, 10) + assert.False(t, meta.HasPreviousResults) + assert.True(t, meta.HasNextResults) + + // paginated - middle + opts.Page = 1 + results, count, meta, err = ds.ListQueries(context.Background(), opts) + require.NoError(t, err) + require.Equal(t, 3, len(results)) + require.Equal(t, "Zach", results[0].AuthorName) + require.Equal(t, "zwass@fleet.co", results[0].AuthorEmail) + require.True(t, results[0].DiscardData) + assert.Equal(t, count, 10) + assert.True(t, meta.HasPreviousResults) + assert.True(t, meta.HasNextResults) + + // paginated - end + opts.Page = 3 + results, count, meta, err = ds.ListQueries(context.Background(), opts) + require.NoError(t, err) + require.Equal(t, 1, len(results)) + require.Equal(t, "Zach", results[0].AuthorName) + require.Equal(t, "zwass@fleet.co", results[0].AuthorEmail) + require.True(t, results[0].DiscardData) + assert.Equal(t, count, 10) + assert.True(t, meta.HasPreviousResults) + assert.False(t, meta.HasNextResults) + + // paginated - past end + opts.Page = 4 + results, count, meta, err = ds.ListQueries(context.Background(), opts) + require.NoError(t, err) + require.Equal(t, 0, len(results)) + assert.Equal(t, count, 10) + assert.True(t, meta.HasPreviousResults) + assert.False(t, meta.HasNextResults) + + opts.PerPage = 0 + opts.Page = 0 + results, count, meta, err = ds.ListQueries(context.Background(), opts) require.NoError(t, err) require.Equal(t, 10, len(results)) require.Equal(t, "Zach", results[0].AuthorName) require.Equal(t, "zwass@fleet.co", results[0].AuthorEmail) require.True(t, results[0].DiscardData) + assert.Equal(t, count, 10) + assert.False(t, meta.HasPreviousResults) + assert.False(t, meta.HasNextResults) idWithAgg := results[0].ID @@ -490,7 +607,7 @@ func testQueriesList(t *testing.T, ds *Datastore) { ) require.NoError(t, err) - results, err = ds.ListQueries(context.Background(), opts) + results, _, _, err = ds.ListQueries(context.Background(), opts) require.NoError(t, err) require.Equal(t, 10, len(results)) @@ -698,7 +815,7 @@ func testObserverCanRunQuery(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - queries, err := ds.ListQueries(context.Background(), fleet.ListQueryOptions{}) + queries, _, _, err := ds.ListQueries(context.Background(), fleet.ListQueryOptions{}) require.NoError(t, err) for _, q := range queries { @@ -731,9 +848,10 @@ func testListQueriesFiltersByTeamID(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - queries, err := ds.ListQueries(context.Background(), fleet.ListQueryOptions{}) + queries, count, _, err := ds.ListQueries(context.Background(), fleet.ListQueryOptions{}) require.NoError(t, err) test.QueryElementsMatch(t, queries, []*fleet.Query{globalQ1, globalQ2, globalQ3}) + assert.Equal(t, count, 3) team, err := ds.NewTeam(context.Background(), &fleet.Team{ Name: "some kind of nature", @@ -766,7 +884,7 @@ func testListQueriesFiltersByTeamID(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - queries, err = ds.ListQueries( + queries, count, _, err = ds.ListQueries( context.Background(), fleet.ListQueryOptions{ TeamID: &team.ID, @@ -774,9 +892,10 @@ func testListQueriesFiltersByTeamID(t *testing.T, ds *Datastore) { ) require.NoError(t, err) test.QueryElementsMatch(t, queries, []*fleet.Query{teamQ1, teamQ2, teamQ3}) + assert.Equal(t, count, 3) // test merge inherited - queries, err = ds.ListQueries( + queries, count, _, err = ds.ListQueries( context.Background(), fleet.ListQueryOptions{ TeamID: &team.ID, @@ -785,9 +904,10 @@ func testListQueriesFiltersByTeamID(t *testing.T, ds *Datastore) { ) require.NoError(t, err) test.QueryElementsMatch(t, queries, []*fleet.Query{globalQ1, globalQ2, globalQ3, teamQ1, teamQ2, teamQ3}) + assert.Equal(t, count, 6) // merge inherited ignored for global queries - queries, err = ds.ListQueries( + queries, count, _, err = ds.ListQueries( context.Background(), fleet.ListQueryOptions{ MergeInherited: true, @@ -795,6 +915,7 @@ func testListQueriesFiltersByTeamID(t *testing.T, ds *Datastore) { ) require.NoError(t, err) test.QueryElementsMatch(t, queries, []*fleet.Query{globalQ1, globalQ2, globalQ3}) + assert.Equal(t, count, 3) } func testListQueriesFiltersByIsScheduled(t *testing.T, ds *Datastore) { @@ -844,12 +965,14 @@ func testListQueriesFiltersByIsScheduled(t *testing.T, ds *Datastore) { } for i, tCase := range testCases { - queries, err := ds.ListQueries( + queries, count, _, err := ds.ListQueries( context.Background(), tCase.opts, ) require.NoError(t, err) test.QueryElementsMatch(t, queries, tCase.expected, i) + assert.Equal(t, count, len(tCase.expected)) + } } diff --git a/server/fleet/app.go b/server/fleet/app.go index 662095832ee1..d2674deae93f 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -1125,6 +1125,9 @@ type ListQueryOptions struct { // MergeInherited merges inherited global queries into the team list. Is only valid when TeamID // is set. MergeInherited bool + // Return queries that are scheduled to run on this platform. One of "macos", + // "windows", or "linux" + Platform *string } type ListActivitiesOptions struct { diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 5d32ff5bebe7..cec4abe8f776 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -86,9 +86,10 @@ type Datastore interface { DeleteQueries(ctx context.Context, ids []uint) (uint, error) // Query returns the query associated with the provided ID. Associated packs should also be loaded. Query(ctx context.Context, id uint) (*Query, error) - // ListQueries returns a list of queries with the provided sorting and paging options. Associated packs should also + // ListQueries returns a list of queries filtered with the provided sorting and pagination + // options, a count of total queries on all pages, and pagination metadata. Associated packs should also // be loaded. - ListQueries(ctx context.Context, opt ListQueryOptions) ([]*Query, error) + ListQueries(ctx context.Context, opt ListQueryOptions) ([]*Query, int, *PaginationMetadata, error) // ListScheduledQueriesForAgents returns a list of scheduled queries (without stats) for the // given teamID. If teamID is nil, then all scheduled queries for the 'global' team are returned. ListScheduledQueriesForAgents(ctx context.Context, teamID *uint, queryReportsDisabled bool) ([]*Query, error) diff --git a/server/fleet/service.go b/server/fleet/service.go index ffb054b71894..a04f1570c243 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -276,7 +276,7 @@ type Service interface { // and only non-scheduled queries will be returned if `*scheduled == false`. // If mergeInherited is true and a teamID is provided, then queries from the global team will be // included in the results. - ListQueries(ctx context.Context, opt ListOptions, teamID *uint, scheduled *bool, mergeInherited bool) ([]*Query, error) + ListQueries(ctx context.Context, opt ListOptions, teamID *uint, scheduled *bool, mergeInherited bool, platform *string) ([]*Query, int, *PaginationMetadata, error) GetQuery(ctx context.Context, id uint) (*Query, error) // GetQueryReportResults returns all the stored results of a query for hosts the requestor has access to. // Returns a boolean indicating whether the report is clipped. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 9abfe07bcc53..8088028c0c6e 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -71,7 +71,7 @@ type DeleteQueriesFunc func(ctx context.Context, ids []uint) (uint, error) type QueryFunc func(ctx context.Context, id uint) (*fleet.Query, error) -type ListQueriesFunc func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, error) +type ListQueriesFunc func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) type ListScheduledQueriesForAgentsFunc func(ctx context.Context, teamID *uint, queryReportsDisabled bool) ([]*fleet.Query, error) @@ -3090,7 +3090,7 @@ func (s *DataStore) Query(ctx context.Context, id uint) (*fleet.Query, error) { return s.QueryFunc(ctx, id) } -func (s *DataStore) ListQueries(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, error) { +func (s *DataStore) ListQueries(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { s.mu.Lock() s.ListQueriesFuncInvoked = true s.mu.Unlock() diff --git a/server/service/global_schedule.go b/server/service/global_schedule.go index c75d86048650..b2a01d813d39 100644 --- a/server/service/global_schedule.go +++ b/server/service/global_schedule.go @@ -37,7 +37,7 @@ func getGlobalScheduleEndpoint(ctx context.Context, request interface{}, svc fle } func (svc *Service) GetGlobalScheduledQueries(ctx context.Context, opts fleet.ListOptions) ([]*fleet.ScheduledQuery, error) { - queries, err := svc.ListQueries(ctx, opts, nil, ptr.Bool(true), false) // teamID == nil means global + queries, _, _, err := svc.ListQueries(ctx, opts, nil, ptr.Bool(true), false, nil) // teamID == nil means global if err != nil { return nil, err } diff --git a/server/service/global_schedule_test.go b/server/service/global_schedule_test.go index 4656ca2df48d..5bc20d00e77d 100644 --- a/server/service/global_schedule_test.go +++ b/server/service/global_schedule_test.go @@ -36,8 +36,8 @@ func TestGlobalScheduleAuth(t *testing.T) { ) error { return nil } - ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, error) { - return nil, nil + ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil } ds.NewQueryFunc = func(ctx context.Context, query *fleet.Query, opts ...fleet.OptionalArg) (*fleet.Query, error) { return &fleet.Query{}, nil diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 3d51df8b4201..f593af37d900 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -3242,6 +3242,7 @@ func (s *integrationTestSuite) TestScheduledQueries() { var listQryResp listQueriesResponse s.DoJSON("GET", "/api/latest/fleet/queries", nil, http.StatusOK, &listQryResp) assert.Len(t, listQryResp.Queries, 0) + assert.Equal(t, listQryResp.Count, 0) // create a query var createQueryResp createQueryResponse @@ -3276,9 +3277,12 @@ func (s *integrationTestSuite) TestScheduledQueries() { require.Len(t, listQryResp.Queries, 1) assert.Equal(t, query.Name, listQryResp.Queries[0].Name) - // next page returns nothing + // next page returns nothing, count and meta are correct s.DoJSON("GET", "/api/latest/fleet/queries", nil, http.StatusOK, &listQryResp, "per_page", "2", "page", "1") require.Len(t, listQryResp.Queries, 0) + require.Equal(t, listQryResp.Count, 1) + require.True(t, listQryResp.Meta.HasPreviousResults) + require.False(t, listQryResp.Meta.HasNextResults) // getting that query works var getQryResp getQueryResponse @@ -3422,6 +3426,124 @@ func (s *integrationTestSuite) TestScheduledQueries() { assert.Equal(t, uint(0), delBatchResp.Deleted) } +func (s *integrationTestSuite) TestQueriesPaginationAndPlatformFilter() { + t := s.T() + + // make a few queries + testQueries := []*fleet.Query{ + {Name: "PPTestQuery1", Query: "select 1", Platform: "darwin"}, + {Name: "PPTestQuery2", Query: "select 2", Platform: "linux"}, + {Name: "PPTestQuery3", Query: "select 3", Platform: "windows"}, + {Name: "PPTestQuery4", Query: "select 4", Platform: "darwin,windows,linux"}, + {Name: "PPTestQuery5", Query: "select 5"}, + {Name: "PPTestQuery6", Query: "select 6"}, + {Name: "PPTestQuery7", Query: "select 7"}, + {Name: "PPTestQuery8", Query: "select 8"}, + {Name: "PPTestQuery9", Query: "select 9"}, + {Name: "PPTestQuery10", Query: "select 10"}, + } + var createQueryResp createQueryResponse + for _, q := range testQueries { + reqQuery := &fleet.QueryPayload{ + Name: &q.Name, + Query: &q.Query, + Platform: &q.Platform, + } + s.DoJSON("POST", "/api/latest/fleet/queries", reqQuery, http.StatusOK, &createQueryResp) + require.Equal(t, createQueryResp.Query.Name, q.Name) + require.Equal(t, createQueryResp.Query.Platform, q.Platform) + } + + var listQryResp listQueriesResponse + queryNameToMatch := "TestQuery" + + // Test pagination, no filter + + // middle of the pages + s.DoJSON("GET", "/api/latest/fleet/queries", nil, http.StatusOK, &listQryResp, "query", queryNameToMatch, "per_page", "2", "page", "1") + require.Len(t, listQryResp.Queries, 2) + require.Equal(t, listQryResp.Count, 10) + require.True(t, listQryResp.Meta.HasPreviousResults) + require.True(t, listQryResp.Meta.HasNextResults) + + // first and only page + s.DoJSON("GET", "/api/latest/fleet/queries", nil, http.StatusOK, &listQryResp, "query", queryNameToMatch, "per_page", "10", "page", "0") + require.Len(t, listQryResp.Queries, 10) + require.Equal(t, listQryResp.Count, 10) + require.False(t, listQryResp.Meta.HasPreviousResults) + require.False(t, listQryResp.Meta.HasNextResults) + + // first of a few pages + s.DoJSON("GET", "/api/latest/fleet/queries", nil, http.StatusOK, &listQryResp, "query", queryNameToMatch, "per_page", "2", "page", "0") + require.Len(t, listQryResp.Queries, 2) + require.Equal(t, listQryResp.Count, 10) + require.False(t, listQryResp.Meta.HasPreviousResults) + require.True(t, listQryResp.Meta.HasNextResults) + + // last page + s.DoJSON("GET", "/api/latest/fleet/queries", nil, http.StatusOK, &listQryResp, "query", queryNameToMatch, "per_page", "5", "page", "1") + require.Len(t, listQryResp.Queries, 5) + require.Equal(t, listQryResp.Count, 10) + require.True(t, listQryResp.Meta.HasPreviousResults) + require.False(t, listQryResp.Meta.HasNextResults) + + // after last page + s.DoJSON("GET", "/api/latest/fleet/queries", nil, http.StatusOK, &listQryResp, "query", queryNameToMatch, "per_page", "2", "page", "5") + require.Len(t, listQryResp.Queries, 0) + require.Equal(t, listQryResp.Count, 10) + require.True(t, listQryResp.Meta.HasPreviousResults) + require.False(t, listQryResp.Meta.HasNextResults) + + // test platform filtering + + s.DoJSON("GET", "/api/latest/fleet/queries", nil, http.StatusOK, &listQryResp, "platform", "macos") + require.Len(t, listQryResp.Queries, 8) + require.Equal(t, listQryResp.Count, 8) + require.False(t, listQryResp.Meta.HasPreviousResults) + require.False(t, listQryResp.Meta.HasNextResults) + require.Equal(t, "darwin", listQryResp.Queries[0].Platform) + require.Equal(t, "darwin,windows,linux", listQryResp.Queries[1].Platform) + + s.DoJSON("GET", "/api/latest/fleet/queries", nil, http.StatusOK, &listQryResp, "platform", "windows") + require.Len(t, listQryResp.Queries, 8) + require.Equal(t, listQryResp.Count, 8) + require.False(t, listQryResp.Meta.HasPreviousResults) + require.False(t, listQryResp.Meta.HasNextResults) + require.Equal(t, "windows", listQryResp.Queries[0].Platform) + require.Equal(t, "darwin,windows,linux", listQryResp.Queries[1].Platform) + + s.DoJSON("GET", "/api/latest/fleet/queries", nil, http.StatusOK, &listQryResp, "platform", "linux") + require.Len(t, listQryResp.Queries, 8) + require.Equal(t, listQryResp.Count, 8) + require.False(t, listQryResp.Meta.HasPreviousResults) + require.False(t, listQryResp.Meta.HasNextResults) + require.Equal(t, "linux", listQryResp.Queries[0].Platform) + require.Equal(t, "darwin,windows,linux", listQryResp.Queries[1].Platform) + + s.DoJSON("GET", "/api/latest/fleet/queries", nil, http.StatusOK, &listQryResp, "platform", "linux", "per_page", "1", "page", "0") + require.Len(t, listQryResp.Queries, 1) + require.Equal(t, listQryResp.Count, 8) + require.False(t, listQryResp.Meta.HasPreviousResults) + require.True(t, listQryResp.Meta.HasNextResults) + require.Equal(t, "linux", listQryResp.Queries[0].Platform) + + s.DoJSON("GET", "/api/latest/fleet/queries", nil, http.StatusOK, &listQryResp, "platform", "linux", "per_page", "1", "page", "1") + require.Len(t, listQryResp.Queries, 1) + require.Equal(t, listQryResp.Count, 8) + require.True(t, listQryResp.Meta.HasPreviousResults) + require.True(t, listQryResp.Meta.HasNextResults) + require.Equal(t, "darwin,windows,linux", listQryResp.Queries[0].Platform) + + s.DoJSON("GET", "/api/latest/fleet/queries", nil, http.StatusBadRequest, &listQryResp, "platform", "lucas", "per_page", "1", "page", "1") + + // delete them by name + var delByNameResp deleteQueryResponse + // for _, qId := range testQueryIds { + for _, q := range testQueries { + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/queries/%s", q.Name), nil, http.StatusOK, &delByNameResp) + } +} + func (s *integrationTestSuite) TestHostDeviceMapping() { t := s.T() ctx := context.Background() diff --git a/server/service/osquery_test.go b/server/service/osquery_test.go index d0f37e1e9071..69e4abc26b24 100644 --- a/server/service/osquery_test.go +++ b/server/service/osquery_test.go @@ -2587,8 +2587,8 @@ func TestUpdateHostIntervals(t *testing.T) { ds.ListPacksForHostFunc = func(ctx context.Context, hid uint) ([]*fleet.Pack, error) { return []*fleet.Pack{}, nil } - ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, error) { - return nil, nil + ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil } testCases := []struct { diff --git a/server/service/queries.go b/server/service/queries.go index 07b9c43a69b1..fbe8d1d02a57 100644 --- a/server/service/queries.go +++ b/server/service/queries.go @@ -60,11 +60,15 @@ type listQueriesRequest struct { // TeamID url argument set to 0 means global. TeamID uint `query:"team_id,optional"` MergeInherited bool `query:"merge_inherited,optional"` + // only return queries targeted to run on this platform + Platform string `query:"platform,optional"` } type listQueriesResponse struct { - Queries []fleet.Query `json:"queries"` - Err error `json:"error,omitempty"` + Queries []fleet.Query `json:"queries"` + Count int `json:"count"` + Meta *fleet.PaginationMetadata `json:"meta"` + Err error `json:"error,omitempty"` } func (r listQueriesResponse) error() error { return r.Err } @@ -77,7 +81,12 @@ func listQueriesEndpoint(ctx context.Context, request interface{}, svc fleet.Ser teamID = &req.TeamID } - queries, err := svc.ListQueries(ctx, req.ListOptions, teamID, nil, req.MergeInherited) + var urlPlatform *string + if req.Platform != "" { + urlPlatform = &req.Platform + } + + queries, count, meta, err := svc.ListQueries(ctx, req.ListOptions, teamID, nil, req.MergeInherited, urlPlatform) if err != nil { return listQueriesResponse{Err: err}, nil } @@ -86,30 +95,55 @@ func listQueriesEndpoint(ctx context.Context, request interface{}, svc fleet.Ser for _, query := range queries { respQueries = append(respQueries, *query) } + return listQueriesResponse{ Queries: respQueries, + Count: count, + Meta: meta, }, nil } -func (svc *Service) ListQueries(ctx context.Context, opt fleet.ListOptions, teamID *uint, scheduled *bool, mergeInherited bool) ([]*fleet.Query, error) { +func (svc *Service) ListQueries(ctx context.Context, opt fleet.ListOptions, teamID *uint, scheduled *bool, mergeInherited bool, urlPlatform *string) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { // Check the user is allowed to list queries on the given team. if err := svc.authz.Authorize(ctx, &fleet.Query{ TeamID: teamID, }, fleet.ActionRead); err != nil { - return nil, err + return nil, 0, nil, err } - queries, err := svc.ds.ListQueries(ctx, fleet.ListQueryOptions{ + // always include metadata for queries + opt.IncludeMetadata = true + + var dbPlatform *string + if urlPlatform != nil { + // validate platform filter + if *urlPlatform == "macos" { + // More user-friendly API param "macos" is called "darwin" in the datastore + dbPlatform = ptr.String("darwin") + } else { + dbPlatform = urlPlatform + } + if strings.Contains(*urlPlatform, ",") { + return nil, 0, nil, &fleet.BadRequestError{Message: "queries can only be filtered by one platform at a time"} + } + targetableDBPlatforms := []string{"darwin", "windows", "linux"} + if !slices.Contains(targetableDBPlatforms, *dbPlatform) { + return nil, 0, nil, &fleet.BadRequestError{Message: fmt.Sprintf("platform %q cannot be a scheduled query target, supported platforms are: %s", *dbPlatform, strings.Join(targetableDBPlatforms, ","))} + } + } + + queries, count, meta, err := svc.ds.ListQueries(ctx, fleet.ListQueryOptions{ ListOptions: opt, TeamID: teamID, IsScheduled: scheduled, MergeInherited: mergeInherited, + Platform: dbPlatform, }) if err != nil { - return nil, err + return nil, 0, nil, err } - return queries, nil + return queries, count, meta, nil } //////////////////////////////////////////////////////////////////////////////// @@ -745,7 +779,7 @@ func getQuerySpecsEndpoint(ctx context.Context, request interface{}, svc fleet.S } func (svc *Service) GetQuerySpecs(ctx context.Context, teamID *uint) ([]*fleet.QuerySpec, error) { - queries, err := svc.ListQueries(ctx, fleet.ListOptions{}, teamID, nil, false) + queries, _, _, err := svc.ListQueries(ctx, fleet.ListOptions{}, teamID, nil, false, nil) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting queries") } diff --git a/server/service/queries_test.go b/server/service/queries_test.go index 856bfa6c7491..147ccaa96544 100644 --- a/server/service/queries_test.go +++ b/server/service/queries_test.go @@ -400,8 +400,8 @@ func TestQueryAuth(t *testing.T) { ds.DeleteQueriesFunc = func(ctx context.Context, ids []uint) (uint, error) { return 0, nil } - ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { - return nil, nil + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil } ds.ApplyQueriesFunc = func(ctx context.Context, authID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{}) error { return nil @@ -647,7 +647,7 @@ func TestQueryAuth(t *testing.T) { _, err = svc.QueryReportIsClipped(ctx, tt.qid, fleet.DefaultMaxQueryReportRows) checkAuthErr(t, tt.shouldFailRead, err) - _, err = svc.ListQueries(ctx, fleet.ListOptions{}, query.TeamID, nil, false) + _, _, _, err = svc.ListQueries(ctx, fleet.ListOptions{}, query.TeamID, nil, false, nil) checkAuthErr(t, tt.shouldFailRead, err) teamName := "" diff --git a/server/service/team_schedule.go b/server/service/team_schedule.go index e48b53067cc4..0c1d60e5a5a0 100644 --- a/server/service/team_schedule.go +++ b/server/service/team_schedule.go @@ -47,7 +47,7 @@ func (svc Service) GetTeamScheduledQueries(ctx context.Context, teamID uint, opt if teamID != 0 { teamID_ = &teamID } - queries, err := svc.ListQueries(ctx, opts, teamID_, ptr.Bool(true), false) + queries, _, _, err := svc.ListQueries(ctx, opts, teamID_, ptr.Bool(true), false, nil) if err != nil { return nil, err } diff --git a/server/service/team_schedule_test.go b/server/service/team_schedule_test.go index f6a52cdadbd9..3c504df52093 100644 --- a/server/service/team_schedule_test.go +++ b/server/service/team_schedule_test.go @@ -15,8 +15,8 @@ func TestTeamScheduleAuth(t *testing.T) { ds := new(mock.Store) svc, ctx := newTestService(t, ds, nil, nil) - ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, error) { - return nil, nil + ds.ListQueriesFunc = func(ctx context.Context, opt fleet.ListQueryOptions) ([]*fleet.Query, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil } ds.QueryFunc = func(ctx context.Context, id uint) (*fleet.Query, error) { if id == 99 { // for testing modify and delete of a schedule diff --git a/server/service/testing_client.go b/server/service/testing_client.go index 062939a0e779..d511e05daebd 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -131,7 +131,7 @@ func (ts *withServer) commonTearDownTest(t *testing.T) { } } - queries, err := ts.ds.ListQueries(ctx, fleet.ListQueryOptions{}) + queries, _, _, err := ts.ds.ListQueries(ctx, fleet.ListQueryOptions{}) require.NoError(t, err) queryIDs := make([]uint, 0, len(queries)) for _, query := range queries {