[Fleet] ON Week - Port Integrations UI to @tanstack/query (#146714)

## Summary

Migrates data fetching in `applications/integrations` to
`@tanstack/query` - https://tanstack.com/query/latest

This migration gives us some powerful tools around data fetching and
state management in React, and follows suit with several other Kibana
plugins that have migrated to `react-query` in the recent months.

I took some time to write up some dev docs around this change in
bf9527a2bf/x-pack/plugins/fleet/dev_docs/react_query.md
that should hopefully represent a good start in explaining the new data
fetching patterns in detail.

Note: I'm reviving this ON week PR from November after a few months, so
there might be non-RQ data fetching changes that have landed in `main`
since I initially filed these changes. If you find anything please let
me know and I'll happily migrate that too.

I'll also be doing some more manual testing of critical integrations UX
flows to make sure there's no regressions here, though everything I can
think of has looked good so far 🙂

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Kyle Pollich 2023-02-08 10:23:06 -05:00 committed by GitHub
parent d87733160c
commit 031f67bb49
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 818 additions and 416 deletions

View file

@ -60,7 +60,7 @@ pageLoadAssetSize:
files: 22673
filesManagement: 18683
fileUpload: 25664
fleet: 126917
fleet: 142263
globalSearch: 29696
globalSearchBar: 50403
globalSearchProviders: 25554

View file

@ -0,0 +1,238 @@
# `@tanstack/query` Usage in Fleet + Integrations
This document seeks to outline the Fleet + Integrations apps' usage of [`@tanstack/query`](https://tanstack.com/query/latest) - formally known generally as `react-query`. When we talk about the React-specific adapter for `@tanstack/query`, we'll use the library name `react-query`. Since Kibana doesn't have Vue, Solid, or Svelte plugins, we don't need to be worried about the other client implementations. This is a library for asynchronous state management that's most commonly utilized for data fetching logic. `@tanstack/query` helps developers write consistent state management logic around asynchronous operations, while providing end users with a performant, "jank-free" experience.
## Helpful Links
- `react-query` [docs](https://tanstack.com/query/latest/docs/react/overview)
- [Practical React Query](https://tanstack.com/query/latest/docs/react/overview) by maintainer [TkDodo](https://github.com/tkdodo)
- This series is long but extremely helpful. A highly recommended read for anyone working with data fetching in Fleet/Integrations!
- `@tanstack/query` source code on GitHub: https://github.com/TanStack/query
## How Fleet/Integrations uses custom data fetching hooks
Historically, Fleet/Integrations have used homegrown data fetching hooks in a common folder at [`public/hooks/use_request`](https://github.com/elastic/kibana/tree/main/x-pack/plugins/fleet/public/hooks/use_request). Each `.ts` file in this directory contains one or more data fetching hooks related to a particular resource or concept. For example, here's what some data fetching hooks for `packages` and `categories` might look like:
```ts
// use_request/epm.ts
export const useGetCategories = (query: GetCategoriesRequest['query'] = {}) => {
return useRequest<GetCategoriesResponse>({
path: epmRouteService.getCategoriesPath(),
method: 'get',
query,
});
};
export const sendGetCategories = (query: GetCategoriesRequest['query'] = {}) => {
return sendRequest<GetCategoriesResponse>({
path: epmRouteService.getCategoriesPath(),
method: 'get',
query,
});
};
export const useGetPackages = (query: GetPackagesRequest['query'] = {}) => {
return useRequest<GetPackagesResponse>({
path: epmRouteService.getListPath(),
method: 'get',
query,
});
};
export const sendGetPackages = (query: GetPackagesRequest['query'] = {}) => {
return sendRequest<GetPackagesResponse>({
path: epmRouteService.getListPath(),
method: 'get',
query,
});
};
```
<details>
<summary>What are <code>useRequest</code> and <code>sendRequest</code>?</summary>
The `useRequest` and `sendRequest` methods are common across all of these data fetching hooks, and use Kibana's provide `useRequest` hook and `sendRequest` helper with some additional logic on top. e.g.
```ts
// use_request/use_request.ts - excerpts for clarity
import {
sendRequest as _sendRequest,
useRequest as _useRequest,
} from '@kbn/es-ui-shared-plugin/public';
export const sendRequest = <D = any, E = RequestError>(
config: SendRequestConfig
): Promise<SendRequestResponse<D, E>> => {
if (!httpClient) {
throw new Error('sendRequest has no http client set');
}
return _sendRequest<D, E>(httpClient, config);
};
export const useRequest = <D = any, E = RequestError>(config: UseRequestConfig) => {
if (!httpClient) {
throw new Error('sendRequest has no http client set');
}
return _useRequest<D, E>(httpClient, config);
};
```
</details>
Consuming these data fetching hooks might look something like this
```tsx
// applications/integrations/sections/epm/screens/detail/settings/update_button.tsx
const handleClickUpgradePolicies = useCallback(async () => {
if (isUpgradingPackagePolicies) {
return;
}
setIsUpdateModalVisible(false);
setIsUpgradingPackagePolicies(true);
await installPackage({ name, version, title });
await sendUpgradePackagePolicy(
// Only upgrade policies that don't have conflicts
packagePolicyIds.filter(
(id) => !dryRunData?.find((dryRunRecord) => dryRunRecord.diff?.[0].id === id)?.hasErrors
)
);
setIsUpgradingPackagePolicies(false);
notifications.toasts.addSuccess({
title: toMountPoint(
<FormattedMessage
id="xpack.fleet.integrations.packageUpdateSuccessTitle"
defaultMessage="Updated {title} and upgraded policies"
values={{ title }}
/>,
{ theme$ }
),
text: toMountPoint(
<FormattedMessage
id="xpack.fleet.integrations.packageUpdateSuccessDescription"
defaultMessage="Successfully updated {title} and upgraded policies"
values={{ title }}
/>,
{ theme$ }
),
});
navigateToNewSettingsPage();
}, [
dryRunData,
installPackage,
isUpgradingPackagePolicies,
name,
navigateToNewSettingsPage,
notifications.toasts,
packagePolicyIds,
setIsUpgradingPackagePolicies,
title,
version,
theme$,
]);
```
In the "custom data fetching" hooks world, there are a few big problems:
1. Caching, cancellation, deduping/debouncing successive requests, and optimizations around re-renders are an afterthought
2. Mutations in particular are extremely verbose, as we need to "wire up" all error/loading state, "post-mutation" operations, etc
3. Revalidating queries from elsewhere in the component tree (e.g. update the agent policy table when a new agent policy is saved) is a tricky operation usually solved by intermittent polling or a `location.reload()`
## How `react-query` helps
`react-query` handles many of the "big problems" above out-of-the-box. By providing a basic key/value based cache for queries, consistent utilities around state transitions of async operations, and robust revalidation helpers, `react-query` makes working with the state around data fetching much more predictable and pleasant.
### How Fleet/Integrations uses `react-query`
There's a bit of setup involved to actually get `react-query` up and running. First and foremost, each Kibana application is wrapped in a `<QueryClientProvider>` that handles `react-query`'s internal query cache and various React context needs. e.g.
```tsx
//...
<EuiThemeProvider darkMode={isDarkMode}>
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={true} />
<UIExtensionsContext.Provider value={extensions}>
<FleetStatusProvider>
<Router history={history}>
<PackageInstallProvider notifications={startServices.notifications} theme$={theme$}>
<FlyoutContextProvider>{children}</FlyoutContextProvider>
</PackageInstallProvider>
</Router>
</FleetStatusProvider>
</UIExtensionsContext.Provider>
</QueryClientProvider>
</EuiThemeProvider>
```
We also set up `react-query`'s [dev tools](https://tanstack.com/query/v4/docs/react/devtools), which provide a useful developer console for debugging query and mutation state across the whole application.
Another step required to use `react-query` in Fleet/Integrations is the introduction of a specialized data fetching utility. `react-query` operations expect a slightly different structure than what Kibana's `useRequest` and `sendRequest` helpers. For this purpose, we introduce the `sendRequestForRq` helper, e.g.
```ts
// Sends requests with better ergonomics for React Query, e.g. throw error rather
// than resolving with an `error` property in the result. Also returns `data` directly
// as opposed to { data } in a response object.
export const sendRequestForRq = async <D = any, E = RequestError>(
config: SendRequestConfig
): Promise<D> => {
if (!httpClient) {
throw new Error('sendRequest has no http client set');
}
const response = await _sendRequest<D, E>(httpClient, config);
if (response.error) {
throw response.error;
}
// Data can't be null so long as `_sendRequest` did not throw
return response.data!;
};
```
So, with those pieces of setup in mind, adding a new query or mutation looks like this:
```ts
export function useGetCategoriesQuery(query: GetCategoriesRequest['query'] = {}) {
return useQuery<GetCategoriesResponse, RequestError>(['categories', query], () =>
sendRequestForRq<GetCategoriesResponse>({
path: epmRouteService.getCategoriesPath(),
method: 'get',
query,
})
);
}
export const useGetPackagesQuery = (query: GetPackagesRequest['query']) => {
return useQuery<GetPackagesResponse, RequestError>(['get-packages', query.prerelease], () =>
sendRequestForRq<GetPackagesResponse>({
path: epmRouteService.getListPath(),
method: 'get',
query,
})
);
};
export const useUpdatePackageMutation = () => {
return useMutation<UpdatePackageResponse, RequestError, UpdatePackageArgs>(
({ pkgName, pkgVersion, body }: UpdatePackageArgs) =>
sendRequestForRq<UpdatePackageResponse>({
path: epmRouteService.getUpdatePath(pkgName, pkgVersion),
method: 'put',
body,
})
);
};
```
### `react-query` operation naming conventions
For `react-query` operations defined in `use_request/`, try to use a naming convention along the lines of `use{Action}{Resource}{Query/Mutation}` for your hooks. This helps with consistency and makes the intent of every data fetching operation clear.

View file

@ -15,6 +15,8 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
import useObservable from 'react-use/lib/useObservable';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import type { TopNavMenuData } from '@kbn/navigation-plugin/public';
@ -62,6 +64,8 @@ import { DebugPage } from './sections/debug';
const FEEDBACK_URL = 'https://ela.st/fleet-feedback';
const queryClient = new QueryClient();
const ErrorLayout: FunctionComponent<{ isAddIntegrationsPath: boolean }> = ({
isAddIntegrationsPath,
children,
@ -257,18 +261,21 @@ export const FleetAppContext: React.FC<{
<KibanaVersionContext.Provider value={kibanaVersion}>
<KibanaThemeProvider theme$={theme$}>
<EuiThemeProvider darkMode={isDarkMode}>
<UIExtensionsContext.Provider value={extensions}>
<FleetStatusProvider>
<Router history={history}>
<PackageInstallProvider
notifications={startServices.notifications}
theme$={theme$}
>
<FlyoutContextProvider>{children}</FlyoutContextProvider>
</PackageInstallProvider>
</Router>
</FleetStatusProvider>
</UIExtensionsContext.Provider>
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={true} />
<UIExtensionsContext.Provider value={extensions}>
<FleetStatusProvider>
<Router history={history}>
<PackageInstallProvider
notifications={startServices.notifications}
theme$={theme$}
>
<FlyoutContextProvider>{children}</FlyoutContextProvider>
</PackageInstallProvider>
</Router>
</FleetStatusProvider>
</UIExtensionsContext.Provider>
</QueryClientProvider>
</EuiThemeProvider>
</KibanaThemeProvider>
</KibanaVersionContext.Provider>

View file

@ -10,7 +10,11 @@ import { i18n } from '@kbn/i18n';
import { splitPkgKey } from '../../../../../../../common/services';
import { useGetPackageInfoByKey, useLink, useFleetServerHostsForPolicy } from '../../../../hooks';
import {
useGetPackageInfoByKeyQuery,
useLink,
useFleetServerHostsForPolicy,
} from '../../../../hooks';
import type { AddToPolicyParams, CreatePackagePolicyParams } from '../types';
@ -70,7 +74,7 @@ export const CreatePackagePolicyMultiPage: CreatePackagePolicyParams = ({
data: packageInfoData,
error: packageInfoError,
isLoading: isPackageInfoLoading,
} = useGetPackageInfoByKey(pkgName, pkgVersion, { prerelease, full: true });
} = useGetPackageInfoByKeyQuery(pkgName, pkgVersion, { prerelease, full: true });
const {
agentPolicy,

View file

@ -20,7 +20,7 @@ import {
sendGetAgentStatus,
useIntraAppState,
useStartServices,
useGetPackageInfoByKey,
useGetPackageInfoByKeyQuery,
} from '../../../../hooks';
jest.mock('../../../../hooks', () => {
@ -42,10 +42,10 @@ jest.mock('../../../../hooks', () => {
sendGetOneAgentPolicy: jest.fn().mockResolvedValue({
data: { item: { id: 'agent-policy-1', name: 'Agent policy 1', namespace: 'default' } },
}),
useGetPackageInfoByKeyQuery: jest.fn(),
sendGetSettings: jest.fn().mockResolvedValue({
data: { item: {} },
}),
useGetPackageInfoByKey: jest.fn(),
sendCreatePackagePolicy: jest
.fn()
.mockResolvedValue({ data: { item: { id: 'policy-1', inputs: [] } } }),
@ -186,7 +186,7 @@ describe('when on the package policy create page', () => {
isLoading: false,
};
(useGetPackageInfoByKey as jest.Mock).mockReturnValue(mockPackageInfo);
(useGetPackageInfoByKeyQuery as jest.Mock).mockReturnValue(mockPackageInfo);
});
describe('and Route state is provided via Fleet HashRouter', () => {
@ -355,7 +355,7 @@ describe('when on the package policy create page', () => {
});
test('should create agent policy without sys monitoring when new hosts is selected for system integration', async () => {
(useGetPackageInfoByKey as jest.Mock).mockReturnValue({
(useGetPackageInfoByKeyQuery as jest.Mock).mockReturnValue({
...mockPackageInfo,
data: {
item: {

View file

@ -27,7 +27,7 @@ import { useCancelAddPackagePolicy } from '../hooks';
import { splitPkgKey } from '../../../../../../../common/services';
import { generateNewAgentPolicyWithDefaults } from '../../../../services';
import type { NewAgentPolicy } from '../../../../types';
import { useConfig, sendGetAgentStatus, useGetPackageInfoByKey } from '../../../../hooks';
import { useConfig, sendGetAgentStatus, useGetPackageInfoByKeyQuery } from '../../../../hooks';
import {
Loading,
Error as ErrorComponent,
@ -97,7 +97,7 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({
data: packageInfoData,
error: packageInfoError,
isLoading: isPackageInfoLoading,
} = useGetPackageInfoByKey(pkgName, pkgVersion, { full: true, prerelease });
} = useGetPackageInfoByKeyQuery(pkgName, pkgVersion, { full: true, prerelease });
const packageInfo = useMemo(() => {
if (packageInfoData && packageInfoData.item) {
return packageInfoData.item;

View file

@ -9,12 +9,12 @@ import React from 'react';
import { createFleetTestRendererMock } from '../../../../../../mock';
import type { Agent, AgentPolicy } from '../../../../types';
import { useGetPackageInfoByKey } from '../../../../../../hooks/use_request/epm';
import { useGetPackageInfoByKeyQuery } from '../../../../../../hooks/use_request/epm';
import { AgentDashboardLink } from './agent_dashboard_link';
const mockedUseGetPackageInfoByKey = useGetPackageInfoByKey as jest.MockedFunction<
typeof useGetPackageInfoByKey
const mockedUseGetPackageInfoByKeyQuery = useGetPackageInfoByKeyQuery as jest.MockedFunction<
typeof useGetPackageInfoByKeyQuery
>;
jest.mock('../../../../../../hooks/use_fleet_status', () => ({
@ -27,14 +27,14 @@ jest.mock('../../../../../../hooks/use_request/epm');
describe('AgentDashboardLink', () => {
it('should enable the button if elastic_agent package is installed and policy has monitoring enabled', async () => {
mockedUseGetPackageInfoByKey.mockReturnValue({
mockedUseGetPackageInfoByKeyQuery.mockReturnValue({
isLoading: false,
data: {
item: {
status: 'installed',
},
},
} as ReturnType<typeof useGetPackageInfoByKey>);
} as ReturnType<typeof useGetPackageInfoByKeyQuery>);
const testRenderer = createFleetTestRendererMock();
const result = testRenderer.render(
@ -57,14 +57,14 @@ describe('AgentDashboardLink', () => {
});
it('should not enable the button if elastic_agent package is not installed and policy has monitoring enabled', async () => {
mockedUseGetPackageInfoByKey.mockReturnValue({
mockedUseGetPackageInfoByKeyQuery.mockReturnValue({
isLoading: false,
data: {
item: {
status: 'not_installed',
},
},
} as ReturnType<typeof useGetPackageInfoByKey>);
} as ReturnType<typeof useGetPackageInfoByKeyQuery>);
const testRenderer = createFleetTestRendererMock();
const result = testRenderer.render(
@ -88,14 +88,14 @@ describe('AgentDashboardLink', () => {
});
it('should link to the agent policy settings tab if logs and metrics are not enabled for that policy', async () => {
mockedUseGetPackageInfoByKey.mockReturnValue({
mockedUseGetPackageInfoByKeyQuery.mockReturnValue({
isLoading: false,
data: {
item: {
status: 'installed',
},
},
} as ReturnType<typeof useGetPackageInfoByKey>);
} as ReturnType<typeof useGetPackageInfoByKeyQuery>);
const testRenderer = createFleetTestRendererMock();
const result = testRenderer.render(

View file

@ -10,7 +10,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { EuiButton, EuiToolTip } from '@elastic/eui';
import styled from 'styled-components';
import { useGetPackageInfoByKey, useKibanaLink, useLink } from '../../../../hooks';
import { useGetPackageInfoByKeyQuery, useKibanaLink, useLink } from '../../../../hooks';
import type { Agent, AgentPolicy } from '../../../../types';
import {
FLEET_ELASTIC_AGENT_PACKAGE,
@ -18,7 +18,7 @@ import {
} from '../../../../../../../common/constants';
function useAgentDashboardLink(agent: Agent) {
const { isLoading, data } = useGetPackageInfoByKey(FLEET_ELASTIC_AGENT_PACKAGE);
const { isLoading, data } = useGetPackageInfoByKeyQuery(FLEET_ELASTIC_AGENT_PACKAGE);
const isInstalled = data?.item.status === 'installed';

View file

@ -11,6 +11,8 @@ import { EuiErrorBoundary, EuiPortal } from '@elastic/eui';
import type { History } from 'history';
import { Router, Redirect, Route, Switch } from 'react-router-dom';
import useObservable from 'react-use/lib/useObservable';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { KibanaContextProvider, RedirectAppLinks } from '@kbn/kibana-react-plugin/public';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
@ -39,6 +41,8 @@ import { PackageInstallProvider, UIExtensionsContext, FlyoutContextProvider } fr
import { IntegrationsHeader } from './components/header';
import { AgentEnrollmentFlyout } from './components';
const queryClient = new QueryClient();
const EmptyContext = () => <></>;
/**
@ -79,28 +83,31 @@ export const IntegrationsAppContext: React.FC<{
<KibanaVersionContext.Provider value={kibanaVersion}>
<KibanaThemeProvider theme$={theme$}>
<EuiThemeProvider darkMode={isDarkMode}>
<UIExtensionsContext.Provider value={extensions}>
<FleetStatusProvider>
<startServices.customIntegrations.ContextProvider>
<CloudContext>
<Router history={history}>
<AgentPolicyContextProvider>
<PackageInstallProvider
notifications={startServices.notifications}
theme$={theme$}
>
<FlyoutContextProvider>
<IntegrationsHeader {...{ setHeaderActionMenu, theme$ }} />
{children}
<Chat />
</FlyoutContextProvider>
</PackageInstallProvider>
</AgentPolicyContextProvider>
</Router>
</CloudContext>
</startServices.customIntegrations.ContextProvider>
</FleetStatusProvider>
</UIExtensionsContext.Provider>
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={true} />
<UIExtensionsContext.Provider value={extensions}>
<FleetStatusProvider>
<startServices.customIntegrations.ContextProvider>
<CloudContext>
<Router history={history}>
<AgentPolicyContextProvider>
<PackageInstallProvider
notifications={startServices.notifications}
theme$={theme$}
>
<FlyoutContextProvider>
<IntegrationsHeader {...{ setHeaderActionMenu, theme$ }} />
{children}
<Chat />
</FlyoutContextProvider>
</PackageInstallProvider>
</AgentPolicyContextProvider>
</Router>
</CloudContext>
</startServices.customIntegrations.ContextProvider>
</FleetStatusProvider>
</UIExtensionsContext.Provider>
</QueryClientProvider>
</EuiThemeProvider>
</KibanaThemeProvider>
</KibanaVersionContext.Provider>

View file

@ -14,5 +14,3 @@ export * from './use_agent_policy_context';
export * from './use_integrations_state';
export * from './use_confirm_force_install';
export * from './use_confirm_open_unverified';
export * from './use_packages';
export * from './use_categories';

View file

@ -1,55 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect, useCallback, useState } from 'react';
import type { RequestError } from '../../fleet/hooks';
import { sendGetCategories } from '../../fleet/hooks';
import type { GetCategoriesResponse } from '../types';
export function useCategories(prerelease?: boolean) {
const [data, setData] = useState<GetCategoriesResponse | undefined>();
const [error, setError] = useState<RequestError | undefined>();
const [isLoading, setIsLoading] = useState(true);
const [isPrereleaseEnabled, setIsPrereleaseEnabled] = useState(prerelease);
const fetchData = useCallback(async () => {
if (prerelease === undefined) {
return;
}
if (isPrereleaseEnabled === prerelease) {
return;
}
setIsPrereleaseEnabled(prerelease);
setIsLoading(true);
try {
const res = await sendGetCategories({
include_policy_templates: true,
prerelease,
});
if (res.error) {
throw res.error;
}
if (res.data) {
setData(res.data);
}
} catch (err) {
setError(err);
}
setIsLoading(false);
}, [prerelease, isPrereleaseEnabled]);
useEffect(() => {
fetchData();
}, [fetchData]);
return {
data,
error,
isLoading,
};
}

View file

@ -5,7 +5,10 @@
* 2.0.
*/
// TODO: Refactor this away from constate, which is unmaintained, as this is the only
// usage of it across the Fleet codebase
import createContainer from 'constate';
import React, { useCallback, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n-react';

View file

@ -1,56 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect, useCallback, useState } from 'react';
import type { RequestError } from '../../fleet/hooks';
import { sendGetPackages } from '../../fleet/hooks';
import type { GetPackagesResponse } from '../types';
export function usePackages(prerelease?: boolean) {
const [data, setData] = useState<GetPackagesResponse | undefined>();
const [error, setError] = useState<RequestError | undefined>();
const [isLoading, setIsLoading] = useState(true);
const [isPrereleaseEnabled, setIsPrereleaseEnabled] = useState(prerelease);
const fetchData = useCallback(async () => {
if (prerelease === undefined) {
return;
}
if (isPrereleaseEnabled === prerelease) {
return;
}
setIsPrereleaseEnabled(prerelease);
setIsLoading(true);
try {
const res = await sendGetPackages({
category: '',
excludeInstallStatus: true,
prerelease,
});
if (res.error) {
throw res.error;
}
if (res.data) {
setData(res.data);
}
} catch (err) {
setError(err);
}
setIsLoading(false);
}, [prerelease, isPrereleaseEnabled]);
useEffect(() => {
fetchData();
}, [fetchData]);
return {
data,
error,
isLoading,
};
}

View file

@ -5,4 +5,4 @@
* 2.0.
*/
export { useIsFirstTimeAgentUser } from './use_is_first_time_agent_user';
export { useIsFirstTimeAgentUserQuery } from './use_is_first_time_agent_user';

View file

@ -5,50 +5,46 @@
* 2.0.
*/
import { useEffect, useState } from 'react';
import { sendGetAgentPolicies, sendGetAgents } from '../../../../../hooks';
import { useGetAgentPoliciesQuery, useGetAgentsQuery } from '../../../../../hooks';
import { policyHasFleetServer } from '../../../../../services';
interface UseIsFirstTimeAgentUserResponse {
isLoading: boolean;
isFirstTimeAgentUser?: boolean;
isLoading?: boolean;
}
export const useIsFirstTimeAgentUser = (): UseIsFirstTimeAgentUserResponse => {
const [result, setResult] = useState<UseIsFirstTimeAgentUserResponse>({ isLoading: true });
useEffect(() => {
if (!result.isLoading) {
return;
}
export const useIsFirstTimeAgentUserQuery = (): UseIsFirstTimeAgentUserResponse => {
const {
data: agentPolicies,
isLoading: areAgentPoliciesLoading,
isFetched: areAgentsFetched,
} = useGetAgentPoliciesQuery({
full: true,
});
const getIsFirstTimeAgentUser = async () => {
const { data: agentPoliciesData } = await sendGetAgentPolicies({
full: true,
});
// now get all agents that are NOT part of a fleet server policy
const serverPolicyIdsQuery = (agentPolicies?.items || [])
.filter((item) => policyHasFleetServer(item))
.map((p) => `policy_id:${p.id}`)
.join(' or ');
// now get all agents that are NOT part of a fleet server policy
const serverPolicyIdsQuery = (agentPoliciesData?.items || [])
.filter((item) => policyHasFleetServer(item))
.map((p) => `policy_id:${p.id}`)
.join(' or ');
// get agents that are not unenrolled and not fleet server
const kuery =
`not (_exists_:"unenrolled_at")` +
(serverPolicyIdsQuery.length ? ` and not (${serverPolicyIdsQuery})` : '');
// get agents that are not unenrolled and not fleet server
const kuery =
`not (_exists_:"unenrolled_at")` +
(serverPolicyIdsQuery.length ? ` and not (${serverPolicyIdsQuery})` : '');
const { data: agents, isLoading: areAgentsLoading } = useGetAgentsQuery(
{
page: 1,
perPage: 1, // we only need to know if there is at least one non-fleet agent
showInactive: true,
kuery,
},
{ enabled: areAgentsFetched } // don't run the query until agent policies are loaded
);
const { data: agentStatusData } = await sendGetAgents({
page: 1,
perPage: 1, // we only need to know if there is at least one non-fleet agent
showInactive: true,
kuery,
});
setResult({ isLoading: false, isFirstTimeAgentUser: agentStatusData?.total === 0 });
};
getIsFirstTimeAgentUser();
}, [result]);
return result;
return {
isLoading: areAgentPoliciesLoading || areAgentsLoading,
isFirstTimeAgentUser: agents?.data?.total === 0,
};
};

View file

@ -39,14 +39,14 @@ import {
useBreadcrumbs,
useStartServices,
useAuthz,
usePermissionCheck,
usePermissionCheckQuery,
useIntegrationsStateContext,
useGetSettings,
useGetSettingsQuery,
} from '../../../../hooks';
import { INTEGRATIONS_ROUTING_PATHS } from '../../../../constants';
import { ExperimentalFeaturesService } from '../../../../services';
import {
useGetPackageInfoByKey,
useGetPackageInfoByKeyQuery,
useLink,
useAgentPolicyContext,
useIsGuidedOnboardingActive,
@ -63,7 +63,7 @@ import {
import type { WithHeaderLayoutProps } from '../../../../layouts';
import { WithHeaderLayout } from '../../../../layouts';
import { useIsFirstTimeAgentUser } from './hooks';
import { useIsFirstTimeAgentUserQuery } from './hooks';
import { getInstallPkgRouteOptions } from './utils';
import {
IntegrationAgentPolicyCount,
@ -114,17 +114,24 @@ export function Detail() {
const { getFromIntegrations } = useIntegrationsStateContext();
const { pkgkey, panel } = useParams<DetailParams>();
const { getHref, getPath } = useLink();
const canInstallPackages = useAuthz().integrations.installPackages;
const canReadPackageSettings = useAuthz().integrations.readPackageSettings;
const canReadIntegrationPolicies = useAuthz().integrations.readIntegrationPolicies;
const permissionCheck = usePermissionCheck();
const missingSecurityConfiguration =
!permissionCheck.data?.success && permissionCheck.data?.error === 'MISSING_SECURITY';
const userCanInstallPackages = canInstallPackages && permissionCheck.data?.success;
const history = useHistory();
const { pathname, search, hash } = useLocation();
const queryParams = useMemo(() => new URLSearchParams(search), [search]);
const integration = useMemo(() => queryParams.get('integration'), [queryParams]);
const canInstallPackages = useAuthz().integrations.installPackages;
const canReadPackageSettings = useAuthz().integrations.readPackageSettings;
const canReadIntegrationPolicies = useAuthz().integrations.readIntegrationPolicies;
const {
data: permissionCheck,
error: permissionCheckError,
isLoading: isPermissionCheckLoading,
} = usePermissionCheckQuery();
const missingSecurityConfiguration =
!permissionCheck?.success && permissionCheckError === 'MISSING_SECURITY';
const userCanInstallPackages = canInstallPackages && permissionCheck?.success;
const services = useStartServices();
const isCloud = !!services?.cloud?.cloudId;
const { createPackagePolicyMultiPageLayout: isExperimentalAddIntegrationPageEnabled } =
@ -163,7 +170,7 @@ export function Detail() {
boolean | undefined
>();
const { data: settings } = useGetSettings();
const { data: settings } = useGetSettingsQuery();
useEffect(() => {
const isEnabled = Boolean(settings?.item.prerelease_integrations_enabled);
@ -176,9 +183,8 @@ export function Detail() {
data: packageInfoData,
error: packageInfoError,
isLoading: packageInfoLoading,
isInitialRequest: packageIsInitialRequest,
resendRequest: refreshPackageInfo,
} = useGetPackageInfoByKey(pkgName, pkgVersion, {
refetch: refetchPackageInfo,
} = useGetPackageInfoByKeyQuery(pkgName, pkgVersion, {
prerelease: prereleaseIntegrationsEnabled,
});
@ -186,7 +192,7 @@ export function Detail() {
const [latestPrereleaseVersion, setLatestPrereleaseVersion] = useState<string | undefined>();
// fetch latest GA version (prerelease=false)
const { data: packageInfoLatestGAData } = useGetPackageInfoByKey(pkgName, '', {
const { data: packageInfoLatestGAData } = useGetPackageInfoByKeyQuery(pkgName, '', {
prerelease: false,
});
@ -199,7 +205,7 @@ export function Detail() {
}, [packageInfoLatestGAData?.item]);
// fetch latest Prerelease version (prerelease=true)
const { data: packageInfoLatestPrereleaseData } = useGetPackageInfoByKey(pkgName, '', {
const { data: packageInfoLatestPrereleaseData } = useGetPackageInfoByKeyQuery(pkgName, '', {
prerelease: true,
});
@ -208,7 +214,7 @@ export function Detail() {
}, [packageInfoLatestPrereleaseData?.item.version]);
const { isFirstTimeAgentUser = false, isLoading: firstTimeUserLoading } =
useIsFirstTimeAgentUser();
useIsFirstTimeAgentUserQuery();
const isGuidedOnboardingActive = useIsGuidedOnboardingActive(pkgName);
// Refresh package info when status change
@ -220,17 +226,14 @@ export function Detail() {
}
if (oldPackageInstallStatus === 'not_installed' && packageInstallStatus === 'installed') {
setOldPackageStatus(packageInstallStatus);
refreshPackageInfo();
refetchPackageInfo();
}
}, [packageInstallStatus, oldPackageInstallStatus, refreshPackageInfo]);
}, [packageInstallStatus, oldPackageInstallStatus, refetchPackageInfo]);
const isLoading =
(packageInfoLoading && !packageIsInitialRequest) ||
permissionCheck.isLoading ||
firstTimeUserLoading;
const isLoading = packageInfoLoading || isPermissionCheckLoading || firstTimeUserLoading;
const showCustomTab =
useUIExtension(packageInfoData?.item.name ?? '', 'package-detail-custom') !== undefined;
useUIExtension(packageInfoData?.item?.name ?? '', 'package-detail-custom') !== undefined;
// Track install status state
useEffect(() => {
@ -698,7 +701,7 @@ export function Detail() {
defaultMessage="Error loading integration details"
/>
}
error={packageInfoError}
error={packageInfoError.message}
/>
) : isLoading || !packageInfo ? (
<Loading />

View file

@ -29,7 +29,7 @@ import type {
KibanaAssetType,
} from '../../../../../types';
import { entries } from '../../../../../types';
import { useGetCategories } from '../../../../../hooks';
import { useGetCategoriesQuery } from '../../../../../hooks';
import { AssetTitleMap, DisplayedAssets, ServiceTitleMap } from '../../../constants';
import { NoticeModal } from './notice_modal';
@ -59,10 +59,10 @@ const Replacements = euiStyled(EuiFlexItem)`
`;
export const Details: React.FC<Props> = memo(({ packageInfo }) => {
const { data: categoriesData, isLoading: isLoadingCategories } = useGetCategories();
const { data: categoriesData, isLoading: isLoadingCategories } = useGetCategoriesQuery();
const packageCategories: string[] = useMemo(() => {
if (!isLoadingCategories && categoriesData && categoriesData.response) {
return categoriesData.response
if (!isLoadingCategories && categoriesData?.items) {
return categoriesData.items
.filter((category) => packageInfo.categories?.includes(category.id as PackageSpecCategory))
.map((category) => category.title);
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import React from 'react';
import {
EuiCodeBlock,
EuiLoadingContent,
@ -19,7 +19,7 @@ import {
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { sendGetFileByPath, useStartServices } from '../../../../../hooks';
import { useGetFileByPathQuery, useStartServices } from '../../../../../hooks';
interface Props {
licenseName?: string;
@ -33,23 +33,17 @@ export const LicenseModal: React.FunctionComponent<Props> = ({
onClose,
}) => {
const { notifications } = useStartServices();
const [licenseText, setLicenseText] = useState<string | undefined>(undefined);
useEffect(() => {
async function fetchData() {
try {
const { data } = await sendGetFileByPath(licensePath);
setLicenseText(data || '');
} catch (err) {
notifications.toasts.addError(err, {
title: i18n.translate('xpack.fleet.epm.errorLoadingLicense', {
defaultMessage: 'Error loading license information',
}),
});
}
}
fetchData();
}, [licensePath, notifications]);
const { data: licenseText, error: licenseError } = useGetFileByPathQuery(licensePath);
if (licenseError) {
notifications.toasts.addError(licenseError, {
title: i18n.translate('xpack.fleet.epm.errorLoadingLicense', {
defaultMessage: 'Error loading license information',
}),
});
}
return (
<EuiModal maxWidth={true} onClose={onClose}>
<EuiModalHeader>

View file

@ -99,6 +99,7 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps
const getPackageInstallStatus = useGetPackageInstallStatus();
const packageInstallStatus = getPackageInstallStatus(name);
const { pagination, pageSizeOptions, setPagination } = useUrlPagination();
const {
data,
isLoading,

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
import { FormattedMessage } from '@kbn/i18n-react';
import semverLt from 'semver/functions/lt';
@ -25,15 +25,15 @@ import { i18n } from '@kbn/i18n';
import type { Observable } from 'rxjs';
import type { CoreTheme } from '@kbn/core/public';
import type { PackageInfo, UpgradePackagePolicyDryRunResponse } from '../../../../../types';
import type { PackageInfo } from '../../../../../types';
import { InstallStatus } from '../../../../../types';
import {
useGetPackagePolicies,
useGetPackagePoliciesQuery,
useGetPackageInstallStatus,
useLink,
sendUpgradePackagePolicyDryRun,
sendUpdatePackage,
useStartServices,
useUpgradePackagePolicyDryRunQuery,
useUpdatePackageMutation,
} from '../../../../../hooks';
import {
PACKAGE_POLICY_SAVED_OBJECT_TYPE,
@ -100,15 +100,30 @@ interface Props {
export const SettingsPage: React.FC<Props> = memo(({ packageInfo, theme$ }: Props) => {
const { name, title, latestVersion, version, keepPoliciesUpToDate } = packageInfo;
const [dryRunData, setDryRunData] = useState<UpgradePackagePolicyDryRunResponse | null>();
const [isUpgradingPackagePolicies, setIsUpgradingPackagePolicies] = useState<boolean>(false);
const getPackageInstallStatus = useGetPackageInstallStatus();
const { data: packagePoliciesData } = useGetPackagePolicies({
const { data: packagePoliciesData } = useGetPackagePoliciesQuery({
perPage: SO_SEARCH_LIMIT,
page: 1,
kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${name}`,
});
const packagePolicyIds = useMemo(
() => packagePoliciesData?.items.map(({ id }) => id),
[packagePoliciesData]
);
const { data: dryRunData } = useUpgradePackagePolicyDryRunQuery(
packagePolicyIds ?? [],
latestVersion,
{
enabled: packagePolicyIds && packagePolicyIds.length > 0,
}
);
const updatePackageMutation = useUpdatePackageMutation();
const { notifications } = useStartServices();
const shouldShowKeepPoliciesUpToDateSwitch = useMemo(() => {
@ -124,72 +139,60 @@ export const SettingsPage: React.FC<Props> = memo(({ packageInfo, theme$ }: Prop
);
const handleKeepPoliciesUpToDateSwitchChange = useCallback(() => {
const saveKeepPoliciesUpToDate = async () => {
try {
setKeepPoliciesUpToDateSwitchValue((prev) => !prev);
setKeepPoliciesUpToDateSwitchValue((prev) => !prev);
await sendUpdatePackage(packageInfo.name, packageInfo.version, {
updatePackageMutation.mutate(
{
pkgName: packageInfo.name,
pkgVersion: packageInfo.version,
body: {
keepPoliciesUpToDate: !keepPoliciesUpToDateSwitchValue,
});
notifications.toasts.addSuccess({
title: i18n.translate('xpack.fleet.integrations.integrationSaved', {
defaultMessage: 'Integration settings saved',
}),
text: !keepPoliciesUpToDateSwitchValue
? i18n.translate('xpack.fleet.integrations.keepPoliciesUpToDateEnabledSuccess', {
defaultMessage:
'Fleet will automatically keep integration policies up to date for {title}',
values: { title },
})
: i18n.translate('xpack.fleet.integrations.keepPoliciesUpToDateDisabledSuccess', {
defaultMessage:
'Fleet will not automatically keep integration policies up to date for {title}',
values: { title },
}),
});
} catch (error) {
notifications.toasts.addError(error, {
title: i18n.translate('xpack.fleet.integrations.integrationSavedError', {
defaultMessage: 'Error saving integration settings',
}),
toastMessage: i18n.translate('xpack.fleet.integrations.keepPoliciesUpToDateError', {
defaultMessage: 'Error saving integration settings for {title}',
values: { title },
}),
});
},
},
{
onSuccess: () => {
notifications.toasts.addSuccess({
title: i18n.translate('xpack.fleet.integrations.integrationSaved', {
defaultMessage: 'Integration settings saved',
}),
text: !keepPoliciesUpToDateSwitchValue
? i18n.translate('xpack.fleet.integrations.keepPoliciesUpToDateEnabledSuccess', {
defaultMessage:
'Fleet will automatically keep integration policies up to date for {title}',
values: { title },
})
: i18n.translate('xpack.fleet.integrations.keepPoliciesUpToDateDisabledSuccess', {
defaultMessage:
'Fleet will not automatically keep integration policies up to date for {title}',
values: { title },
}),
});
},
onError: (error) => {
notifications.toasts.addError(error, {
title: i18n.translate('xpack.fleet.integrations.integrationSavedError', {
defaultMessage: 'Error saving integration settings',
}),
toastMessage: i18n.translate('xpack.fleet.integrations.keepPoliciesUpToDateError', {
defaultMessage: 'Error saving integration settings for {title}',
values: { title },
}),
});
},
}
};
saveKeepPoliciesUpToDate();
);
}, [
keepPoliciesUpToDateSwitchValue,
notifications.toasts,
packageInfo.name,
packageInfo.version,
title,
updatePackageMutation,
]);
const { status: installationStatus, version: installedVersion } = getPackageInstallStatus(name);
const packageHasUsages = !!packagePoliciesData?.total;
const packagePolicyIds = useMemo(
() => packagePoliciesData?.items.map(({ id }) => id),
[packagePoliciesData]
);
useEffect(() => {
const fetchDryRunData = async () => {
if (packagePolicyIds && packagePolicyIds.length) {
const { data } = await sendUpgradePackagePolicyDryRun(packagePolicyIds, latestVersion);
setDryRunData(data);
}
};
fetchDryRunData();
}, [latestVersion, packagePolicyIds]);
const updateAvailable =
installedVersion && semverLt(installedVersion, latestVersion) ? true : false;

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
@ -24,7 +24,6 @@ import type { CoreTheme } from '@kbn/core/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import type {
GetAgentPoliciesResponse,
PackageInfo,
UpgradePackagePolicyDryRunResponse,
PackagePolicy,
@ -32,13 +31,13 @@ import type {
import { InstallStatus } from '../../../../../types';
import { AGENT_POLICY_SAVED_OBJECT_TYPE, SO_SEARCH_LIMIT } from '../../../../../constants';
import {
sendGetAgentPolicies,
useInstallPackage,
useGetPackageInstallStatus,
sendUpgradePackagePolicy,
useStartServices,
useAuthz,
useLink,
useUpgradePackagePoliciesMutation,
useGetAgentPoliciesQuery,
} from '../../../../../hooks';
interface UpdateButtonProps extends Pick<PackageInfo, 'name' | 'title' | 'version'> {
@ -92,26 +91,17 @@ export const UpdateButton: React.FunctionComponent<UpdateButtonProps> = ({
const [isUpdateModalVisible, setIsUpdateModalVisible] = useState<boolean>(false);
const [upgradePackagePolicies, setUpgradePackagePolicies] = useState<boolean>(true);
const [agentPolicyData, setAgentPolicyData] = useState<GetAgentPoliciesResponse | null>();
useEffect(() => {
const fetchAgentPolicyData = async () => {
if (packagePolicyIds && packagePolicyIds.length > 0) {
const { data } = await sendGetAgentPolicies({
perPage: SO_SEARCH_LIMIT,
page: 1,
// Fetch all agent policies that include one of the eligible package policies
kuery: `${AGENT_POLICY_SAVED_OBJECT_TYPE}.package_policies:${packagePolicyIds
.map((id) => `"${id}"`)
.join(' or ')}`,
});
setAgentPolicyData(data);
}
};
fetchAgentPolicyData();
}, [packagePolicyIds]);
const { data: agentPolicyData } = useGetAgentPoliciesQuery({
perPage: SO_SEARCH_LIMIT,
page: 1,
// Fetch all agent policies that include one of the eligible package policies
kuery: packagePolicyIds.length
? `${AGENT_POLICY_SAVED_OBJECT_TYPE}.package_policies:${packagePolicyIds
.map((id) => `"${id}"`)
.join(' or ')}`
: '',
});
const packagePolicyCount = useMemo(() => packagePolicyIds.length, [packagePolicyIds]);
@ -164,6 +154,8 @@ export const UpdateButton: React.FunctionComponent<UpdateButtonProps> = ({
await installPackage({ name, version, title, fromUpdate: true });
}, [installPackage, name, title, version]);
const upgradePackagePoliciesMutation = useUpgradePackagePoliciesMutation();
const handleClickUpgradePolicies = useCallback(async () => {
if (isUpgradingPackagePolicies) {
return;
@ -174,47 +166,51 @@ export const UpdateButton: React.FunctionComponent<UpdateButtonProps> = ({
await installPackage({ name, version, title });
await sendUpgradePackagePolicy(
// Only upgrade policies that don't have conflicts
packagePolicyIds.filter(
(id) => !dryRunData?.find((dryRunRecord) => dryRunRecord.diff?.[0].id === id)?.hasErrors
)
upgradePackagePoliciesMutation.mutate(
{
// Only upgrade policies that don't have conflicts
packagePolicyIds: packagePolicyIds.filter(
(id) => !dryRunData?.find((dryRunRecord) => dryRunRecord.diff?.[0].id === id)?.hasErrors
),
},
{
onSuccess: () => {
notifications.toasts.addSuccess({
title: toMountPoint(
<FormattedMessage
id="xpack.fleet.integrations.packageUpdateSuccessTitle"
defaultMessage="Updated {title} and upgraded policies"
values={{ title }}
/>,
{ theme$ }
),
text: toMountPoint(
<FormattedMessage
id="xpack.fleet.integrations.packageUpdateSuccessDescription"
defaultMessage="Successfully updated {title} and upgraded policies"
values={{ title }}
/>,
{ theme$ }
),
});
navigateToNewSettingsPage();
},
}
);
setIsUpgradingPackagePolicies(false);
notifications.toasts.addSuccess({
title: toMountPoint(
<FormattedMessage
id="xpack.fleet.integrations.packageUpdateSuccessTitle"
defaultMessage="Updated {title} and upgraded policies"
values={{ title }}
/>,
{ theme$ }
),
text: toMountPoint(
<FormattedMessage
id="xpack.fleet.integrations.packageUpdateSuccessDescription"
defaultMessage="Successfully updated {title} and upgraded policies"
values={{ title }}
/>,
{ theme$ }
),
});
navigateToNewSettingsPage();
}, [
dryRunData,
installPackage,
isUpgradingPackagePolicies,
name,
navigateToNewSettingsPage,
notifications.toasts,
packagePolicyIds,
setIsUpgradingPackagePolicies,
title,
installPackage,
name,
version,
title,
upgradePackagePoliciesMutation,
packagePolicyIds,
dryRunData,
notifications.toasts,
theme$,
navigateToNewSettingsPage,
]);
const updateModal = (

View file

@ -11,7 +11,7 @@ import { uniq, xorBy } from 'lodash';
import type { CustomIntegration } from '@kbn/custom-integrations-plugin/common';
import type { IntegrationPreferenceType } from '../../../components/integration_preference';
import { usePackages, useCategories } from '../../../../../hooks';
import { useGetPackagesQuery, useGetCategoriesQuery } from '../../../../../hooks';
import {
useGetAppendCustomIntegrations,
useGetReplacementCustomIntegrations,
@ -130,7 +130,7 @@ export const useAvailablePackages = () => {
data: eprPackages,
isLoading: isLoadingAllPackages,
error: eprPackageLoadingError,
} = usePackages(prereleaseIntegrationsEnabled);
} = useGetPackagesQuery({ prerelease: prereleaseIntegrationsEnabled });
// Remove Kubernetes package granularity
if (eprPackages?.items) {
@ -185,7 +185,7 @@ export const useAvailablePackages = () => {
data: eprCategoriesRes,
isLoading: isLoadingCategories,
error: eprCategoryLoadingError,
} = useCategories(prereleaseIntegrationsEnabled);
} = useGetCategoriesQuery({ prerelease: prereleaseIntegrationsEnabled });
const eprCategories = useMemo(() => eprCategoriesRes?.items || [], [eprCategoriesRes]);
// Subcategories

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useMemo, useState } from 'react';
import React, { useState } from 'react';
import { Switch, Route } from 'react-router-dom';
import type { CustomIntegration } from '@kbn/custom-integrations-plugin/common';
@ -26,7 +26,7 @@ import type {
IntegrationCardReleaseLabel,
} from '../../../../../../../common/types/models';
import { useGetPackages } from '../../../../hooks';
import { useGetPackagesQuery } from '../../../../hooks';
import type { CategoryFacet, ExtendedIntegrationCategory } from './category_facets';
@ -115,14 +115,12 @@ export const EPMHomePage: React.FC = () => {
const [prereleaseEnabled, setPrereleaseEnabled] = useState<boolean>(false);
// loading packages to find installed ones
const { data: allPackages, isLoading } = useGetPackages({
const { data: allPackages, isLoading } = useGetPackagesQuery({
prerelease: prereleaseEnabled,
});
const installedPackages = useMemo(
() =>
(allPackages?.response || []).filter((pkg) => pkg.status === installationStatuses.Installed),
[allPackages?.response]
const installedPackages = (allPackages?.items || []).filter(
(pkg) => pkg.status === installationStatuses.Installed
);
const unverifiedPackageCount = installedPackages.filter(

View file

@ -10,17 +10,17 @@ import { useRouteMatch } from 'react-router-dom';
// TODO: Needs to be moved
import { EditPackagePolicyForm } from '../../../../../fleet/sections/agent_policy/edit_package_policy_page';
import { useGetOnePackagePolicy, useUIExtension } from '../../../../hooks';
import { useGetOnePackagePolicyQuery, useUIExtension } from '../../../../hooks';
export const Policy = memo(() => {
const {
params: { packagePolicyId },
} = useRouteMatch<{ packagePolicyId: string }>();
const packagePolicy = useGetOnePackagePolicy(packagePolicyId);
const { data: packagePolicyData } = useGetOnePackagePolicyQuery(packagePolicyId);
const extensionView = useUIExtension(
packagePolicy.data?.item?.package?.name ?? '',
packagePolicyData?.item?.package?.name ?? '',
'package-policy-edit'
);

View file

@ -44,6 +44,7 @@ export const FleetStatusProvider: React.FC = ({ children }) => {
isReady: false,
});
// TODO: Refactor to use react-query
const sendGetStatus = useCallback(
async function sendGetStatus() {
try {

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useMemo } from 'react';
import semverLt from 'semver/functions/lt';
import { installationStatuses } from '../../common/constants';
import type { PackagePolicy } from '../types';
import { useGetPackagesQuery } from './use_request/epm';
import { useGetAgentPoliciesQuery } from './use_request/agent_policy';
interface UpdatableIntegration {
currentVersion: string;
policiesToUpgrade: Array<{
id: string;
name: string;
agentsCount: number;
pkgPolicyId: string;
pkgPolicyName: string;
pkgPolicyIntegrationVersion: string;
}>;
}
export const usePackageInstallationsQuery = () => {
const { data: allPackages, isLoading: isLoadingPackages } = useGetPackagesQuery({
prerelease: true,
});
const { data: agentPolicyData, isLoading: isLoadingPolicies } = useGetAgentPoliciesQuery({
full: true,
});
const allInstalledPackages = useMemo(
() => (allPackages?.items || []).filter((pkg) => pkg.status === installationStatuses.Installed),
[allPackages?.items]
);
const updatablePackages = useMemo(
() =>
allInstalledPackages.filter(
(item) =>
'savedObject' in item && semverLt(item.savedObject.attributes.version, item.version)
),
[allInstalledPackages]
);
const updatableIntegrations = useMemo<Map<string, UpdatableIntegration>>(
() =>
(agentPolicyData?.items || []).reduce((result, policy) => {
policy.package_policies?.forEach((pkgPolicy: PackagePolicy) => {
if (!pkgPolicy.package) return false;
const { name, version } = pkgPolicy.package;
const installedPackage = allInstalledPackages.find(
(installedPkg) =>
'savedObject' in installedPkg && installedPkg.savedObject.attributes.name === name
);
if (
installedPackage &&
'savedObject' in installedPackage &&
semverLt(version, installedPackage.savedObject.attributes.version)
) {
const packageData = result.get(name) ?? {
currentVersion: installedPackage.savedObject.attributes.version,
policiesToUpgrade: [],
};
packageData.policiesToUpgrade.push({
id: policy.id,
name: policy.name,
agentsCount: policy.agents ?? 0,
pkgPolicyId: pkgPolicy.id,
pkgPolicyName: pkgPolicy.name,
pkgPolicyIntegrationVersion: version,
});
result.set(name, packageData);
}
});
return result;
}, new Map<string, UpdatableIntegration>()),
[allInstalledPackages, agentPolicyData]
);
return {
allPackages,
allInstalledPackages,
updatablePackages,
updatableIntegrations,
isLoadingPackages,
isLoadingPolicies,
};
};

View file

@ -4,6 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import { agentPolicyRouteService } from '../../services';
@ -22,8 +23,8 @@ import type {
DeleteAgentPolicyResponse,
} from '../../types';
import { useRequest, sendRequest, useConditionalRequest } from './use_request';
import type { SendConditionalRequestConfig } from './use_request';
import { useRequest, sendRequest, useConditionalRequest, sendRequestForRq } from './use_request';
import type { SendConditionalRequestConfig, RequestError } from './use_request';
export const useGetAgentPolicies = (query?: GetAgentPoliciesRequest['query']) => {
return useRequest<GetAgentPoliciesResponse>({
@ -33,6 +34,16 @@ export const useGetAgentPolicies = (query?: GetAgentPoliciesRequest['query']) =>
});
};
export const useGetAgentPoliciesQuery = (query?: GetAgentPoliciesRequest['query']) => {
return useQuery<GetAgentPoliciesResponse, RequestError>(['agentPolicies', query], () =>
sendRequestForRq<GetAgentPoliciesResponse>({
path: agentPolicyRouteService.getListPath(),
method: 'get',
query,
})
);
};
export const sendGetAgentPolicies = (query?: GetAgentPoliciesRequest['query']) => {
return sendRequest<GetAgentPoliciesResponse>({
path: agentPolicyRouteService.getListPath(),

View file

@ -4,6 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import type {
GetActionStatusResponse,
@ -72,6 +73,13 @@ export function useGetAgents(query: GetAgentsRequest['query'], options?: Request
});
}
export function useGetAgentsQuery(
query: GetAgentsRequest['query'],
options: Partial<{ enabled: boolean }> = {}
) {
return useQuery(['agents', query], () => sendGetAgents(query), { enabled: options.enabled });
}
export function sendGetAgents(query: GetAgentsRequest['query'], options?: RequestOptions) {
return sendRequest<GetAgentsResponse>({
method: 'get',

View file

@ -5,10 +5,12 @@
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import { appRoutesService } from '../../services';
import type { CheckPermissionsResponse, GenerateServiceTokenResponse } from '../../types';
import { sendRequest, useRequest } from './use_request';
import { sendRequest, sendRequestForRq, useRequest } from './use_request';
export const sendGetPermissionsCheck = (fleetServerSetup?: boolean) => {
return sendRequest<CheckPermissionsResponse>({
@ -25,6 +27,17 @@ export const sendGenerateServiceToken = () => {
});
};
export const usePermissionCheckQuery = () => {
return useQuery<CheckPermissionsResponse, CheckPermissionsResponse['error']>(
['permissionsCheck'],
() =>
sendRequestForRq<CheckPermissionsResponse>({
path: appRoutesService.getCheckPermissionsPath(),
method: 'get',
})
);
};
export const usePermissionCheck = () => {
return useRequest<CheckPermissionsResponse>({
path: appRoutesService.getCheckPermissionsPath(),

View file

@ -6,8 +6,11 @@
*/
import useAsync from 'react-use/lib/useAsync';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import type { SendRequestResponse } from '@kbn/es-ui-shared-plugin/public';
import { epmRouteService, isVerificationError } from '../../services';
import type {
@ -28,7 +31,8 @@ import { getCustomIntegrations } from '../../services/custom_integrations';
import { useConfirmOpenUnverified } from '../../applications/integrations/hooks/use_confirm_open_unverified';
import { useRequest, sendRequest } from './use_request';
import type { RequestError } from './use_request';
import { useRequest, sendRequest, sendRequestForRq } from './use_request';
export function useGetAppendCustomIntegrations() {
const customIntegrations = getCustomIntegrations();
@ -40,13 +44,15 @@ export function useGetReplacementCustomIntegrations() {
return useAsync(customIntegrations.getReplacementCustomIntegrations, []);
}
export const useGetCategories = (query: GetCategoriesRequest['query'] = {}) => {
return useRequest<GetCategoriesResponse>({
path: epmRouteService.getCategoriesPath(),
method: 'get',
query,
});
};
export function useGetCategoriesQuery(query: GetCategoriesRequest['query'] = {}) {
return useQuery<GetCategoriesResponse, RequestError>(['categories', query], () =>
sendRequestForRq<GetCategoriesResponse>({
path: epmRouteService.getCategoriesPath(),
method: 'get',
query,
})
);
}
export const sendGetCategories = (query: GetCategoriesRequest['query'] = {}) => {
return sendRequest<GetCategoriesResponse>({
@ -64,6 +70,16 @@ export const useGetPackages = (query: GetPackagesRequest['query'] = {}) => {
});
};
export const useGetPackagesQuery = (query: GetPackagesRequest['query']) => {
return useQuery<GetPackagesResponse, RequestError>(['get-packages', query], () =>
sendRequestForRq<GetPackagesResponse>({
path: epmRouteService.getListPath(),
method: 'get',
query,
})
);
};
export const sendGetPackages = (query: GetPackagesRequest['query'] = {}) => {
return sendRequest<GetPackagesResponse>({
path: epmRouteService.getListPath(),
@ -79,7 +95,7 @@ export const useGetLimitedPackages = () => {
});
};
export const useGetPackageInfoByKey = (
export const useGetPackageInfoByKeyQuery = (
pkgName: string,
pkgVersion?: string,
options?: {
@ -92,30 +108,31 @@ export const useGetPackageInfoByKey = (
const [ignoreUnverifiedQueryParam, setIgnoreUnverifiedQueryParam] = useState(
options?.ignoreUnverified
);
const res = useRequest<GetInfoResponse>({
path: epmRouteService.getInfoPath(pkgName, pkgVersion),
method: 'get',
query: {
...options,
...(ignoreUnverifiedQueryParam && { ignoreUnverified: ignoreUnverifiedQueryParam }),
},
});
useEffect(() => {
const confirm = async () => {
const forceInstall = await confirmOpenUnverified(pkgName);
const response = useQuery<GetInfoResponse, RequestError>([pkgName, pkgVersion, options], () =>
sendRequestForRq<GetInfoResponse>({
path: epmRouteService.getInfoPath(pkgName, pkgVersion),
method: 'get',
query: {
...options,
...(ignoreUnverifiedQueryParam && { ignoreUnverified: ignoreUnverifiedQueryParam }),
},
})
);
if (forceInstall) {
setIgnoreUnverifiedQueryParam(true);
}
};
const confirm = async () => {
const forceInstall = await confirmOpenUnverified(pkgName);
if (res.error && isVerificationError(res.error)) {
confirm();
if (forceInstall) {
setIgnoreUnverifiedQueryParam(true);
}
}, [res.error, pkgName, pkgVersion, confirmOpenUnverified]);
};
return res;
if (response?.error && isVerificationError(response?.error)) {
confirm();
}
return response;
};
export const useGetPackageStats = (pkgName: string) => {
@ -148,6 +165,12 @@ export const useGetFileByPath = (filePath: string) => {
});
};
export const useGetFileByPathQuery = (filePath: string) => {
return useQuery<SendRequestResponse<string>, RequestError>(['get-file', filePath], () =>
sendRequest<string>({ path: epmRouteService.getFilePath(filePath), method: 'get' })
);
};
export const sendGetFileByPath = (filePath: string) => {
return sendRequest<string>({
path: epmRouteService.getFilePath(filePath),
@ -184,6 +207,23 @@ export const sendRemovePackage = (pkgName: string, pkgVersion: string, force: bo
});
};
interface UpdatePackageArgs {
pkgName: string;
pkgVersion: string;
body: UpdatePackageRequest['body'];
}
export const useUpdatePackageMutation = () => {
return useMutation<UpdatePackageResponse, RequestError, UpdatePackageArgs>(
({ pkgName, pkgVersion, body }: UpdatePackageArgs) =>
sendRequestForRq<UpdatePackageResponse>({
path: epmRouteService.getUpdatePath(pkgName, pkgVersion),
method: 'put',
body,
})
);
};
export const sendUpdatePackage = (
pkgName: string,
pkgVersion: string,

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { useMutation, useQuery } from '@tanstack/react-query';
import { packagePolicyRouteService } from '../../services';
import type {
CreatePackagePolicyRequest,
@ -22,7 +24,8 @@ import type {
UpgradePackagePolicyResponse,
} from '../../../common/types/rest_spec';
import { sendRequest, useRequest } from './use_request';
import type { RequestError } from './use_request';
import { sendRequest, sendRequestForRq, useRequest } from './use_request';
export const sendCreatePackagePolicy = (body: CreatePackagePolicyRequest['body']) => {
return sendRequest<CreatePackagePolicyResponse>({
@ -51,6 +54,16 @@ export const sendDeletePackagePolicy = (body: DeletePackagePoliciesRequest['body
});
};
export function useGetPackagePoliciesQuery(query: GetPackagePoliciesRequest['query']) {
return useQuery<GetPackagePoliciesResponse, RequestError>(['packagePolicies'], () =>
sendRequestForRq<GetPackagePoliciesResponse>({
method: 'get',
path: packagePolicyRouteService.getListPath(),
query,
})
);
}
export function useGetPackagePolicies(query: GetPackagePoliciesRequest['query']) {
return useRequest<GetPackagePoliciesResponse>({
method: 'get',
@ -67,6 +80,17 @@ export const sendGetPackagePolicies = (query: GetPackagePoliciesRequest['query']
});
};
export const useGetOnePackagePolicyQuery = (packagePolicyId: string) => {
return useQuery<GetOnePackagePolicyResponse, RequestError>(
['packagePolicy', packagePolicyId],
() =>
sendRequestForRq<GetOnePackagePolicyResponse>({
method: 'get',
path: packagePolicyRouteService.getInfoPath(packagePolicyId),
})
);
};
export const useGetOnePackagePolicy = (packagePolicyId: string) => {
return useRequest<GetOnePackagePolicyResponse>({
path: packagePolicyRouteService.getInfoPath(packagePolicyId),
@ -81,6 +105,31 @@ export const sendGetOnePackagePolicy = (packagePolicyId: string) => {
});
};
export function useUpgradePackagePolicyDryRunQuery(
packagePolicyIds: string[],
packageVersion?: string,
{ enabled }: Partial<{ enabled: boolean }> = {}
) {
const body: { packagePolicyIds: string[]; packageVersion?: string } = {
packagePolicyIds,
};
if (packageVersion) {
body.packageVersion = packageVersion;
}
return useQuery<UpgradePackagePolicyDryRunResponse, RequestError>(
['upgradePackagePolicyDryRun', packagePolicyIds, packageVersion],
() =>
sendRequestForRq<UpgradePackagePolicyDryRunResponse>({
path: packagePolicyRouteService.getDryRunPath(),
method: 'post',
body: JSON.stringify(body),
}),
{ enabled }
);
}
export function sendUpgradePackagePolicyDryRun(
packagePolicyIds: string[],
packageVersion?: string
@ -100,6 +149,22 @@ export function sendUpgradePackagePolicyDryRun(
});
}
export function useUpgradePackagePoliciesMutation() {
return useMutation<
UpgradePackagePolicyDryRunResponse,
RequestError,
{ packagePolicyIds: string[] }
>(({ packagePolicyIds }) =>
sendRequestForRq<UpgradePackagePolicyDryRunResponse>({
path: packagePolicyRouteService.getUpgradePath(),
method: 'post',
body: JSON.stringify({
packagePolicyIds,
}),
})
);
}
export function sendUpgradePackagePolicy(packagePolicyIds: string[]) {
return sendRequest<UpgradePackagePolicyResponse>({
path: packagePolicyRouteService.getUpgradePath(),

View file

@ -5,10 +5,22 @@
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import { settingsRoutesService } from '../../services';
import type { PutSettingsResponse, PutSettingsRequest, GetSettingsResponse } from '../../types';
import { sendRequest, useRequest } from './use_request';
import type { RequestError } from './use_request';
import { sendRequest, sendRequestForRq, useRequest } from './use_request';
export function useGetSettingsQuery() {
return useQuery<GetSettingsResponse, RequestError>(['settings'], () =>
sendRequestForRq<GetSettingsResponse>({
method: 'get',
path: settingsRoutesService.getInfoPath(),
})
);
}
export function useGetSettings() {
return useRequest<GetSettingsResponse>({

View file

@ -42,6 +42,26 @@ export const sendRequest = <D = any, E = RequestError>(
return _sendRequest<D, E>(httpClient, config);
};
// Sends requests with better ergonomics for React Query, e.g. throw error rather
// than resolving with an `error` property in the result. Also returns `data` directly
// as opposed to { data } in a response object.
export const sendRequestForRq = async <D = any, E = RequestError>(
config: SendRequestConfig
): Promise<D> => {
if (!httpClient) {
throw new Error('sendRequest has no http client set');
}
const response = await _sendRequest<D, E>(httpClient, config);
if (response.error) {
throw response.error;
}
// Data can't be null so long as `_sendRequest` did not throw
return response.data!;
};
export const useRequest = <D = any, E = RequestError>(config: UseRequestConfig) => {
if (!httpClient) {
throw new Error('sendRequest has no http client set');