[Fleet] Tabular UI for installed integrations (#212582)

This commit is contained in:
Nicolas Chaulet 2025-03-05 08:56:02 -05:00 committed by GitHub
parent 1e3bb05734
commit 680bf587df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1120 additions and 31 deletions

View file

@ -22300,6 +22300,14 @@
"schema": {
"type": "boolean"
}
},
{
"in": "query",
"name": "withPackagePoliciesCount",
"required": false,
"schema": {
"type": "boolean"
}
}
],
"responses": {

View file

@ -22300,6 +22300,14 @@
"schema": {
"type": "boolean"
}
},
{
"in": "query",
"name": "withPackagePoliciesCount",
"required": false,
"schema": {
"type": "boolean"
}
}
],
"responses": {

View file

@ -22139,6 +22139,11 @@ paths:
required: false
schema:
type: boolean
- in: query
name: withPackagePoliciesCount
required: false
schema:
type: boolean
responses:
'200':
content:

View file

@ -24226,6 +24226,11 @@ paths:
required: false
schema:
type: boolean
- in: query
name: withPackagePoliciesCount
required: false
schema:
type: boolean
responses:
'200':
content:

View file

@ -13,6 +13,7 @@ const _allowedExperimentalValues = {
enableAutomaticAgentUpgrades: false,
enableSyncIntegrationsOnRemote: false,
enableSSLSecrets: false,
installedIntegrationsTabularUI: false,
enabledUpgradeAgentlessDeploymentsTask: false,
};

View file

@ -582,6 +582,7 @@ export type PackageListItem = Installable<RegistrySearchResult> & {
integration?: string;
savedObject?: InstallableSavedObject;
installationInfo?: InstallationInfo;
packagePoliciesInfo?: { count: number };
};
export type PackagesGroupedByStatus = Record<ValueOf<InstallationStatus>, PackageList>;
export type PackageInfo =

View file

@ -39,6 +39,7 @@ export interface GetPackagesRequest {
category?: string;
prerelease?: boolean;
excludeInstallStatus?: boolean;
withPackagePoliciesCount?: boolean;
};
}

View file

@ -6,24 +6,26 @@
*/
import { Search as LocalSearch, PrefixIndexStrategy } from 'js-search';
import { useRef } from 'react';
import { useMemo } from 'react';
import type { IntegrationCardItem } from '../sections/epm/screens/home';
import type { PackageListItem } from '../types';
export const searchIdField = 'id';
export const fieldsToSearch = ['name', 'title', 'description'];
export function useLocalSearch(packageList: IntegrationCardItem[], isLoading: boolean) {
const localSearchRef = useRef<LocalSearch>(new LocalSearch(searchIdField));
if (isLoading) {
return localSearchRef;
}
export function useLocalSearch(
packageList: Array<Pick<PackageListItem, 'id' | 'name' | 'title' | 'description'>>,
isInitialLoading: boolean
) {
return useMemo(() => {
if (isInitialLoading) {
return null;
}
const localSearch = new LocalSearch(searchIdField);
localSearch.indexStrategy = new PrefixIndexStrategy();
fieldsToSearch.forEach((field) => localSearch.addIndex(field));
localSearch.addDocuments(packageList);
const localSearch = new LocalSearch(searchIdField);
localSearch.indexStrategy = new PrefixIndexStrategy();
fieldsToSearch.forEach((field) => localSearch.addIndex(field));
localSearch.addDocuments(packageList);
localSearchRef.current = localSearch;
return localSearchRef;
return localSearch;
}, [isInitialLoading, packageList]);
}

View file

@ -103,7 +103,7 @@ export const PackageListGrid: FunctionComponent<PackageListGridProps> = ({
spacer = true,
scrollElementId,
}) => {
const localSearchRef = useLocalSearch(list, !!isLoading);
const localSearch = useLocalSearch(list, !!isLoading);
const [isPopoverOpen, setPopover] = useState(false);
@ -135,18 +135,20 @@ export const PackageListGrid: FunctionComponent<PackageListGridProps> = ({
const filteredPromotedList = useMemo(() => {
if (isLoading) return [];
const searchResults =
(localSearch?.search(searchTerm) as IntegrationCardItem[]).map(
(match) => match[searchIdField]
) ?? [];
const filteredList = searchTerm
? list.filter((item) =>
(localSearchRef.current!.search(searchTerm) as IntegrationCardItem[])
.map((match) => match[searchIdField])
.includes(item[searchIdField])
)
? list.filter((item) => searchResults.includes(item[searchIdField]) ?? [])
: list;
return sortByFeaturedIntegrations
? promoteFeaturedIntegrations(filteredList, selectedCategory)
: filteredList;
}, [isLoading, list, localSearchRef, searchTerm, selectedCategory, sortByFeaturedIntegrations]);
}, [isLoading, list, localSearch, searchTerm, selectedCategory, sortByFeaturedIntegrations]);
const splitSubcategories = (
subcategories: CategoryFacet[] | undefined
): { visibleSubCategories?: CategoryFacet[]; hiddenSubCategories?: CategoryFacet[] } => {

View file

@ -13,8 +13,8 @@ import { installationStatuses } from '../../../../../../../common/constants';
import { INTEGRATIONS_ROUTING_PATHS, INTEGRATIONS_SEARCH_QUERYPARAM } from '../../../../constants';
import { DefaultLayout } from '../../../../layouts';
import { isPackageUpdatable } from '../../../../services';
import { ExperimentalFeaturesService, isPackageUpdatable } from '../../../../services';
import { InstalledIntegrationsPage } from '../installed_integrations';
import { useAuthz, useGetPackagesQuery, useGetSettingsQuery } from '../../../../hooks';
import type { CategoryFacet, ExtendedIntegrationCategory } from './category_facets';
@ -48,6 +48,9 @@ export const EPMHomePage: React.FC = () => {
enabled: isAuthorizedToFetchSettings,
});
const installedIntegrationsTabularUI =
ExperimentalFeaturesService.get()?.installedIntegrationsTabularUI ?? false;
const prereleaseIntegrationsEnabled = settings?.item.prerelease_integrations_enabled ?? false;
const shouldFetchPackages = !isAuthorizedToFetchSettings || isSettingsFetched;
// loading packages to find installed ones
@ -97,7 +100,11 @@ export const EPMHomePage: React.FC = () => {
<Routes>
<Route path={INTEGRATIONS_ROUTING_PATHS.integrations_installed}>
<DefaultLayout section="manage" notificationsBySection={notificationsBySection}>
<InstalledPackages installedPackages={installedPackages} isLoading={isLoading} />
{installedIntegrationsTabularUI ? (
<InstalledIntegrationsPage />
) : (
<InstalledPackages installedPackages={installedPackages} isLoading={isLoading} />
)}
</DefaultLayout>
</Route>
<Route path={INTEGRATIONS_ROUTING_PATHS.integrations_all}>

View file

@ -0,0 +1,74 @@
/*
* 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 React from 'react';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { InstalledPackagesUIInstallationStatus } from '../types';
import { useAddUrlFilters } from '../hooks/use_url_filters';
function getStatusText(status: InstalledPackagesUIInstallationStatus) {
switch (status) {
case 'installed':
return i18n.translate('xpack.fleet.epmInstalledIntegrations.statusIntalledLabel', {
defaultMessage: 'Installed',
});
case 'upgrade_failed':
return i18n.translate('xpack.fleet.epmInstalledIntegrations.statusUpgradeFailedLabel', {
defaultMessage: 'Upgrade failed',
});
case 'upgrade_available':
return i18n.translate('xpack.fleet.epmInstalledIntegrations.statusUpgradeFailedLabel', {
defaultMessage: 'Upgrade',
});
case 'install_failed':
return i18n.translate('xpack.fleet.epmInstalledIntegrations.statusInstallFailedLabel', {
defaultMessage: 'Install failed',
});
default:
return i18n.translate('xpack.fleet.epmInstalledIntegrations.statusNotIntalledLabel', {
defaultMessage: 'Not installed',
});
}
}
function getIconForStatus(status: InstalledPackagesUIInstallationStatus) {
switch (status) {
case 'installed':
return <EuiIcon size="m" type="checkInCircleFilled" color="success" />;
case 'upgrade_available':
return <EuiIcon size="m" type="warning" color="warning" />;
case 'upgrade_failed':
case 'install_failed':
default:
return <EuiIcon size="m" type="error" color="danger" />;
}
}
export const InstallationStatus: React.FunctionComponent<{
status: InstalledPackagesUIInstallationStatus;
}> = React.memo(({ status }) => {
const addUrlFilter = useAddUrlFilters();
return (
<EuiButtonEmpty
size="s"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
addUrlFilter({
installationStatus: [status],
});
}}
>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>{getIconForStatus(status)}</EuiFlexItem>
<EuiFlexItem grow={false}>{getStatusText(status)}</EuiFlexItem>
</EuiFlexGroup>
</EuiButtonEmpty>
);
});

View file

@ -0,0 +1,23 @@
/*
* 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 React from 'react';
import { EuiButton } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
export const InstalledIntegrationsActionMenu: React.FunctionComponent = () => {
const button = (
<EuiButton iconType="arrowDown" iconSide="right" disabled onClick={() => {}}>
<FormattedMessage
id="xpack.fleet.epmInstalledIntegrations.actionButton"
defaultMessage="Actions"
/>
</EuiButton>
);
return button;
};

View file

@ -0,0 +1,168 @@
/*
* 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 {
EuiFlexGroup,
EuiFlexItem,
EuiFilterButton,
EuiFilterGroup,
useEuiTheme,
EuiFieldSearch,
} from '@elastic/eui';
import React, { useMemo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import useDebounce from 'react-use/lib/useDebounce';
import type { InstalledIntegrationsFilter, InstalledPackagesUIInstallationStatus } from '../types';
import { useAddUrlFilters } from '../hooks/use_url_filters';
import { InstalledIntegrationsActionMenu } from './installed_integration_action_menu';
const SEARCH_DEBOUNCE_MS = 250;
export const InstalledIntegrationsSearchBar: React.FunctionComponent<{
filters: InstalledIntegrationsFilter;
countPerStatus: { [k: string]: number | undefined };
customIntegrationsCount: number;
}> = ({ filters, countPerStatus, customIntegrationsCount }) => {
const addUrlFilter = useAddUrlFilters();
const theme = useEuiTheme();
const [searchTerms, setSearchTerms] = useState(filters.q);
useDebounce(
() => {
addUrlFilter({
q: searchTerms,
});
},
SEARCH_DEBOUNCE_MS,
[searchTerms]
);
const statuses: Array<{
iconType: string;
iconColor: string;
status: InstalledPackagesUIInstallationStatus;
label: React.ReactElement;
}> = useMemo(
() => [
{
iconType: 'warning',
iconColor: theme.euiTheme.colors.textWarning,
status: 'upgrade_available',
label: (
<FormattedMessage
id="xpack.fleet.epmInstalledIntegrations.upgradeAvailableFilterLabel"
defaultMessage="Upgrade"
/>
),
},
{
iconType: 'error',
iconColor: theme.euiTheme.colors.textDanger,
status: 'upgrade_failed',
label: (
<FormattedMessage
id="xpack.fleet.epmInstalledIntegrations.upgradeFailerFilterLabel"
defaultMessage="Upgrade failed"
/>
),
},
{
iconType: 'error',
iconColor: theme.euiTheme.colors.textDanger,
status: 'install_failed',
label: (
<FormattedMessage
id="xpack.fleet.epmInstalledIntegrations.installFailerFilterLabel"
defaultMessage="Install failed"
/>
),
},
],
[theme]
);
return (
<div>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiFieldSearch
defaultValue={filters.q}
onChange={(e) => setSearchTerms(e.target.value)}
placeholder={i18n.translate('xpack.fleet.serachBarPlaceholder', {
defaultMessage: 'Search',
})}
fullWidth
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFilterGroup>
{statuses.map((item) => (
<EuiFilterButton
iconType={item.iconType}
iconSide="left"
css={css`
.euiIcon {
color: ${item.iconColor};
}
`}
hasActiveFilters={filters.installationStatus?.includes(item.status)}
numFilters={countPerStatus[item.status] ?? 0}
onClick={() => {
if (!filters.installationStatus?.includes(item.status)) {
addUrlFilter({
installationStatus: [item.status],
});
} else {
addUrlFilter({
installationStatus: [],
});
}
}}
>
{item.label}
</EuiFilterButton>
))}
</EuiFilterGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFilterGroup>
<EuiFilterButton
hasActiveFilters={filters.customIntegrations}
numFilters={customIntegrationsCount}
onClick={() => {
if (filters.customIntegrations) {
addUrlFilter({
customIntegrations: undefined,
});
} else {
addUrlFilter({
customIntegrations: true,
});
}
}}
>
<FormattedMessage
id="xpack.fleet.epmInstalledIntegrations.customFilterLabel"
defaultMessage="Custom"
/>
</EuiFilterButton>
</EuiFilterGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<InstalledIntegrationsActionMenu />
</EuiFlexItem>
</EuiFlexGroup>
</div>
);
};

View file

@ -0,0 +1,208 @@
/*
* 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 React, { useState } from 'react';
import {
EuiBasicTable,
EuiLink,
EuiFlexGroup,
EuiFlexItem,
type CriteriaWithPagination,
EuiToolTip,
EuiText,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { TableIcon } from '../../../../../../../components/package_icon';
import type { PackageListItem } from '../../../../../../../../common';
import { type UrlPagination, useLink, useAuthz } from '../../../../../../../hooks';
import type { InstalledPackageUIPackageListItem } from '../types';
import { InstallationStatus } from './installation_status';
/**
* Wrapper to display a tooltip if element is disabled (i.e. due to insufficient permissions)
*/
const DisabledWrapperTooltip: React.FunctionComponent<{
children: React.ReactElement;
disabled: boolean;
tooltipContent: React.ReactNode;
}> = ({ children, disabled, tooltipContent }) => {
if (disabled) {
return <EuiToolTip content={tooltipContent}>{children}</EuiToolTip>;
} else {
return <>{children}</>;
}
};
export const InstalledIntegrationsTable: React.FunctionComponent<{
installedPackages: InstalledPackageUIPackageListItem[];
total: number;
isLoading: boolean;
pagination: UrlPagination;
}> = ({ installedPackages, total, isLoading, pagination }) => {
const authz = useAuthz();
const { getHref } = useLink();
const [selectedItems, setSelectedItems] = useState<InstalledPackageUIPackageListItem[]>([]);
const { setPagination } = pagination;
const handleTablePagination = React.useCallback(
({ page }: CriteriaWithPagination<InstalledPackageUIPackageListItem>) => {
setPagination({
currentPage: page.index + 1,
pageSize: page.size,
});
},
[setPagination]
);
return (
<>
<EuiText color="subdued" size="s">
<FormattedMessage
id="xpack.fleet.epmInstalledIntegrations.tableTotalCount"
defaultMessage={'Showing {total, plural, one {# integration} other {# integrations}}'}
values={{
total,
}}
/>
</EuiText>
<EuiSpacer size="s" />
<EuiBasicTable
loading={isLoading}
items={installedPackages}
itemId="name"
pagination={{
pageIndex: pagination.pagination.currentPage - 1,
totalItemCount: total,
pageSize: pagination.pagination.pageSize,
showPerPageOptions: true,
pageSizeOptions: pagination.pageSizeOptions,
}}
onChange={handleTablePagination}
selection={{
selectable: () => true,
selected: selectedItems,
onSelectionChange: (newSelectedItems) => {
setSelectedItems(newSelectedItems);
},
}}
columns={[
{
name: i18n.translate(
'xpack.fleet.epmInstalledIntegrations.integrationNameColumnTitle',
{
defaultMessage: 'Integration name',
}
),
render: (item: PackageListItem) => {
const url = getHref('integration_details_overview', {
pkgkey: `${item.name}-${item.version}`,
});
return (
<EuiLink href={url}>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<TableIcon
size="m"
icons={item.icons}
packageName={item.name}
version={item.version}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>{item.title}</EuiFlexItem>
</EuiFlexGroup>
</EuiLink>
);
},
},
{
name: i18n.translate('xpack.fleet.epmInstalledIntegrations.statusColumnTitle', {
defaultMessage: 'Status',
}),
render: (item: InstalledPackageUIPackageListItem) => (
<InstallationStatus status={item.ui.installation_status} />
),
},
{
field: 'version',
width: '126px',
name: i18n.translate('xpack.fleet.epmInstalledIntegrations.versionColumnTitle', {
defaultMessage: 'Version',
}),
},
{
name: i18n.translate(
'xpack.fleet.epmInstalledIntegrations.attachedPoliciesColumnTitle',
{
defaultMessage: 'Attached policies',
}
),
width: '206px',
render: (item: InstalledPackageUIPackageListItem) => {
const policyCount = item.packagePoliciesInfo?.count ?? 0;
if (!policyCount) {
return null;
}
const isDisabled = !authz.fleet.readAgentPolicies;
return (
<DisabledWrapperTooltip
tooltipContent={
<FormattedMessage
id="xpack.fleet.epmInstalledIntegrations.agentPoliciesRequiredPermissionTooltip"
defaultMessage={
"You don't have permissions to view these policies. Contact your administrator."
}
/>
}
disabled={isDisabled}
>
<EuiLink onClick={() => {}} disabled={isDisabled}>
<FormattedMessage
id="xpack.fleet.epmInstalledIntegrations.viewAttachedPoliciesButton"
defaultMessage={
'View {policyCount, plural, one {# policies} other {# policies}}'
}
values={{
policyCount,
}}
/>
</EuiLink>
</DisabledWrapperTooltip>
);
},
},
{
actions: [
{
render: () => {
return <p>test</p>;
},
},
{
name: 'test2',
description: 'test2',
onClick: () => {},
},
{
name: 'test3',
description: 'test3',
onClick: () => {},
},
],
},
]}
/>
</>
);
};

View file

@ -0,0 +1,181 @@
/*
* 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 { renderHook } from '@testing-library/react';
import { useGetPackagesQuery } from '../../../../../../../hooks/use_request/epm';
import { useInstalledIntegrations } from './use_installed_integrations';
jest.mock('../../../../../../../hooks/use_request/epm');
describe('useInstalledIntegrations', () => {
beforeEach(() => {
jest.mocked(useGetPackagesQuery).mockReturnValue({
data: {
items: [
{
id: 'aws',
name: 'aws',
status: 'installed',
version: '1.0.0',
installationInfo: { version: '1.0.0' },
},
{
id: 'azure',
name: 'azure',
status: 'installed',
version: '1.0.0',
installationInfo: {
version: '1.0.0',
latest_install_failed_attempts: [
{
target_version: '1.2.0',
},
],
},
},
{
id: 'nginx',
name: 'nginx',
status: 'install_failed',
version: '1.0.0',
installationInfo: { version: '1.0.0' },
},
{
id: 'apache',
name: 'apache',
status: 'install_failed',
version: '1.0.0',
installationInfo: { version: '1.0.0' },
},
{
id: 'mysql',
name: 'mysql',
status: 'installed',
version: '2.0.0',
installationInfo: { version: '1.0.0' },
},
],
},
} as any);
});
it('should filter not installed packages and compute status in ui property', () => {
const { result } = renderHook(() =>
useInstalledIntegrations({}, { currentPage: 1, pageSize: 10 })
);
expect(result.current).toEqual({
total: 5,
countPerStatus: {
install_failed: 2,
installed: 1,
upgrade_failed: 1,
upgrade_available: 1,
},
customIntegrationsCount: 0,
installedPackages: [
expect.objectContaining({ id: 'aws', ui: { installation_status: 'installed' } }),
expect.objectContaining({ id: 'azure', ui: { installation_status: 'upgrade_failed' } }),
expect.objectContaining({ id: 'nginx', ui: { installation_status: 'install_failed' } }),
expect.objectContaining({ id: 'apache', ui: { installation_status: 'install_failed' } }),
expect.objectContaining({
id: 'mysql',
ui: { installation_status: 'upgrade_available' },
}),
],
});
});
it('should support filtering on status', () => {
const { result } = renderHook(() =>
useInstalledIntegrations(
{
installationStatus: ['install_failed'],
},
{ currentPage: 1, pageSize: 10 }
)
);
expect(result.current).toEqual({
total: 2,
countPerStatus: {
install_failed: 2,
},
customIntegrationsCount: 0,
installedPackages: [
expect.objectContaining({ id: 'nginx', ui: { installation_status: 'install_failed' } }),
expect.objectContaining({ id: 'apache', ui: { installation_status: 'install_failed' } }),
],
});
});
it('should support searching', () => {
const { result } = renderHook(() =>
useInstalledIntegrations(
{
q: 'a',
},
{ currentPage: 1, pageSize: 10 }
)
);
expect(result.current).toEqual(
expect.objectContaining({
total: 3,
installedPackages: [
expect.objectContaining({ id: 'aws' }),
expect.objectContaining({ id: 'azure' }),
expect.objectContaining({ id: 'apache' }),
],
})
);
});
it('should support pagination', () => {
const filters = {};
const { result, rerender } = renderHook(
({ currentPage, pageSize }: { currentPage: number; pageSize: number }) =>
useInstalledIntegrations(filters, { currentPage, pageSize }),
{
initialProps: { currentPage: 1, pageSize: 2 },
}
);
expect(result.current).toEqual(
expect.objectContaining({
total: 5,
installedPackages: [
expect.objectContaining({ id: 'aws' }),
expect.objectContaining({ id: 'azure' }),
],
})
);
rerender({ currentPage: 2, pageSize: 2 });
expect(result.current).toEqual(
expect.objectContaining({
total: 5,
installedPackages: [
expect.objectContaining({ id: 'nginx' }),
expect.objectContaining({ id: 'apache' }),
],
})
);
rerender({ currentPage: 3, pageSize: 2 });
expect(result.current).toEqual(
expect.objectContaining({
total: 5,
installedPackages: [expect.objectContaining({ id: 'mysql' })],
})
);
});
});

View file

@ -0,0 +1,122 @@
/*
* 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 { useLocalSearch } from '../../../../../hooks';
import type { PackageListItem } from '../../../../../../../../common';
import { useGetPackagesQuery, type Pagination } from '../../../../../../../hooks';
import type {
InstalledIntegrationsFilter,
InstalledPackagesUIInstallationStatus,
InstalledPackageUIPackageListItem,
} from '../types';
function getIntegrationStatus(item: PackageListItem): InstalledPackagesUIInstallationStatus {
if (item.status === 'install_failed') {
return 'install_failed';
} else if (item.status === 'installed') {
const isUpgradeFailed = item?.installationInfo?.latest_install_failed_attempts?.some(
(attempt) =>
item.installationInfo && semverLt(item.installationInfo.version, attempt.target_version)
);
const isUpgradeAvailable =
(item?.installationInfo && semverLt(item.installationInfo.version, item.version)) ?? false;
return isUpgradeFailed
? 'upgrade_failed'
: isUpgradeAvailable
? 'upgrade_available'
: 'installed';
}
return item.status ?? 'not_installed';
}
export function useInstalledIntegrations(
filters: InstalledIntegrationsFilter,
pagination: Pagination
) {
const { data, isInitialLoading, isLoading } = useGetPackagesQuery({
withPackagePoliciesCount: true,
});
const internalInstalledPackages: InstalledPackageUIPackageListItem[] = useMemo(
() =>
// Filter not installed packages
(data?.items.filter((item) => item.status !== 'not_installed') ?? [])
// Add extra properties
.map((item) => ({
...item,
ui: {
installation_status: getIntegrationStatus(item),
},
})),
[data]
);
const localSearch = useLocalSearch(internalInstalledPackages, isInitialLoading);
const internalInstalledPackagesFiltered: InstalledPackageUIPackageListItem[] = useMemo(() => {
const searchResults: InstalledPackageUIPackageListItem[] =
filters.q && localSearch
? (localSearch.search(filters.q) as InstalledPackageUIPackageListItem[])
: [];
return (
internalInstalledPackages
// Filter according to filters
.filter((item) => {
const validInstalationStatus = filters.installationStatus
? filters.installationStatus.includes(item.ui.installation_status)
: true;
const validSearchTerms = filters.q ? searchResults.find((s) => s.id === item.id) : true;
const validCustomIntegrations = filters.customIntegrations
? item?.installationInfo?.install_source === 'custom'
: true;
return validInstalationStatus && validSearchTerms && validCustomIntegrations;
})
);
}, [internalInstalledPackages, localSearch, filters]);
const countPerStatus = useMemo(() => {
return internalInstalledPackagesFiltered.reduce((acc, item) => {
if (!acc[item.ui.installation_status]) {
acc[item.ui.installation_status] = 0;
}
(acc[item.ui.installation_status] as number)++;
return acc;
}, {} as { [k: string]: number | undefined });
}, [internalInstalledPackagesFiltered]);
const customIntegrationsCount = useMemo(() => {
return internalInstalledPackagesFiltered.reduce((acc, item) => {
return item?.installationInfo?.install_source === 'custom' ? acc + 1 : acc;
}, 0);
}, [internalInstalledPackagesFiltered]);
const installedPackages: InstalledPackageUIPackageListItem[] = useMemo(() => {
// Pagination
const startAt = (pagination.currentPage - 1) * pagination.pageSize;
return internalInstalledPackagesFiltered.slice(startAt, startAt + pagination.pageSize);
}, [internalInstalledPackagesFiltered, pagination.currentPage, pagination.pageSize]);
return {
total: internalInstalledPackagesFiltered.length,
countPerStatus,
customIntegrationsCount,
installedPackages,
isInitialLoading,
isLoading,
};
}

View file

@ -0,0 +1,84 @@
/*
* 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, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { omit } from 'lodash';
import { useUrlParams } from '../../../../../../../hooks';
import type { InstalledIntegrationsFilter, InstalledPackagesUIInstallationStatus } from '../types';
export function useAddUrlFilters() {
const urlFilters = useUrlFilters();
const { toUrlParams, urlParams } = useUrlParams();
const history = useHistory();
return useCallback(
(filters: Partial<InstalledIntegrationsFilter>) => {
const newFilters = { ...urlFilters, ...filters };
history.push({
search: toUrlParams(
{
...omit(urlParams, 'installationStatus', 'q'),
// Reset current page when changing filters
currentPage: '1',
...(Object.hasOwn(newFilters, 'installationStatus')
? { installationStatus: newFilters.installationStatus }
: {}),
...(Object.hasOwn(newFilters, 'customIntegrations')
? { customIntegrations: newFilters.customIntegrations?.toString() }
: {}),
...(Object.hasOwn(newFilters, 'q') ? { q: newFilters.q } : {}),
},
{
skipEmptyString: true,
}
),
});
},
[urlFilters, urlParams, toUrlParams, history]
);
}
export function useUrlFilters(): InstalledIntegrationsFilter {
const { urlParams } = useUrlParams();
return useMemo(() => {
let installationStatus: InstalledIntegrationsFilter['installationStatus'];
if (urlParams.installationStatus) {
if (typeof urlParams.installationStatus === 'string') {
installationStatus = [
urlParams.installationStatus as InstalledPackagesUIInstallationStatus,
];
} else {
installationStatus =
urlParams.installationStatus as InstalledPackagesUIInstallationStatus[];
}
}
let q: InstalledIntegrationsFilter['q'];
if (typeof urlParams.q === 'string') {
q = urlParams.q;
}
let customIntegrations: InstalledIntegrationsFilter['customIntegrations'];
if (
typeof urlParams.customIntegrations === 'string' &&
urlParams.customIntegrations === 'true'
) {
customIntegrations = true;
}
return {
installationStatus,
customIntegrations,
q,
};
}, [urlParams]);
}

View file

@ -0,0 +1,59 @@
/*
* 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 React from 'react';
import { EuiSpacer } from '@elastic/eui';
import styled from '@emotion/styled';
import { Loading } from '../../../../../../components';
import { useUrlPagination } from '../../../../../../hooks';
import { InstalledIntegrationsTable } from './components/installed_integrations_table';
import { useInstalledIntegrations } from './hooks/use_installed_integrations';
import { useUrlFilters } from './hooks/use_url_filters';
import { InstalledIntegrationsSearchBar } from './components/installed_integrations_search_bar';
const ContentWrapper = styled.div`
max-width: 1200px;
margin: auto;
height: 100%;
`;
export const InstalledIntegrationsPage: React.FunctionComponent = () => {
// State management
const filters = useUrlFilters();
const pagination = useUrlPagination();
const {
installedPackages,
countPerStatus,
customIntegrationsCount,
isLoading,
isInitialLoading,
total,
} = useInstalledIntegrations(filters, pagination.pagination);
if (isInitialLoading) {
return <Loading />;
}
return (
<ContentWrapper>
<InstalledIntegrationsSearchBar
filters={filters}
customIntegrationsCount={customIntegrationsCount}
countPerStatus={countPerStatus}
/>
<EuiSpacer size="l" />
<InstalledIntegrationsTable
total={total}
pagination={pagination}
isLoading={isInitialLoading || isLoading}
installedPackages={installedPackages}
/>
</ContentWrapper>
);
};

View file

@ -0,0 +1,28 @@
/*
* 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 type { PackageListItem } from '../../../../../../../common';
export type InstalledPackagesUIInstallationStatus =
| 'not_installed'
| 'installing'
| 'installed'
| 'install_failed'
| 'upgrade_failed'
| 'upgrade_available';
export type InstalledPackageUIPackageListItem = PackageListItem & {
ui: {
installation_status: InstalledPackagesUIInstallationStatus;
};
};
export interface InstalledIntegrationsFilter {
installationStatus?: InstalledPackagesUIInstallationStatus[];
customIntegrations?: boolean;
q?: string;
}

View file

@ -30,6 +30,20 @@ export const PackageIcon: React.FunctionComponent<
return <Icon size="s" type={iconType} {...euiIconProps} loading="lazy" />;
};
export const TableIcon: React.FunctionComponent<UsePackageIconType & Omit<EuiIconProps, 'type'>> = (
props
) => {
const { icons } = props;
if (icons && icons.length === 1 && icons[0].type === 'eui') {
return <EuiIcon type={icons[0].src} {...props} />;
} else if (icons && icons.length === 1 && icons[0].type === 'svg') {
// @ts-expect-error loading="lazy" is not supported by EuiIcon
return <EuiIcon type={icons[0].src} {...props} loading="lazy" />;
} else {
return <PackageIcon {...props} />;
}
};
export const CardIcon: React.FunctionComponent<UsePackageIconType & Omit<EuiIconProps, 'type'>> = (
props
) => {

View file

@ -13,7 +13,7 @@ import { PAGE_SIZE_OPTIONS, usePagination } from './use_pagination';
import type { Pagination } from './use_pagination';
type SetUrlPagination = (pagination: Pagination) => void;
interface UrlPagination {
export interface UrlPagination {
pagination: Pagination;
setPagination: SetUrlPagination;
pageSizeOptions: number[];

View file

@ -6,7 +6,7 @@
*/
import { useLocation } from 'react-router-dom';
import { parse, stringify } from 'query-string';
import { parse, stringify, type StringifyOptions } from 'query-string';
import { useCallback, useEffect, useState } from 'react';
/**
@ -18,7 +18,10 @@ import { useCallback, useEffect, useState } from 'react';
export function useUrlParams() {
const { search } = useLocation();
const [urlParams, setUrlParams] = useState(() => parse(search));
const toUrlParams = useCallback((params = urlParams) => stringify(params), [urlParams]);
const toUrlParams = useCallback(
(params = urlParams, options?: StringifyOptions) => stringify(params, options),
[urlParams]
);
useEffect(() => {
setUrlParams(parse(search));
}, [search]);

View file

@ -83,6 +83,7 @@ import { getDataStreams } from '../../services/epm/data_streams';
import { NamingCollisionError } from '../../services/epm/packages/custom_integrations/validation/check_naming_collision';
import { DatasetNamePrefixError } from '../../services/epm/packages/custom_integrations/validation/check_dataset_name_format';
import { UPLOAD_RETRY_AFTER_MS } from '../../services/epm/packages/install';
import { getPackagePoliciesCountByPackageName } from '../../services/package_policies/package_policies_aggregation';
const CACHE_CONTROL_10_MINUTES_HEADER: HttpResponseOptions['headers'] = {
'cache-control': 'max-age=600',
@ -105,12 +106,25 @@ export const getListHandler: FleetRequestHandler<
undefined,
TypeOf<typeof GetPackagesRequestSchema.query>
> = async (context, request, response) => {
const savedObjectsClient = (await context.fleet).internalSoClient;
const fleetContext = await context.fleet;
const savedObjectsClient = fleetContext.internalSoClient;
const res = await getPackages({
savedObjectsClient,
...request.query,
});
const flattenedRes = res.map((pkg) => soToInstallationInfo(pkg)) as PackageList;
if (request.query.withPackagePoliciesCount) {
const countByPackage = await getPackagePoliciesCountByPackageName(
appContextService.getInternalUserSOClientForSpaceId(fleetContext.spaceId)
);
for (const item of flattenedRes) {
item.packagePoliciesInfo = {
count: countByPackage[item.name] ?? 0,
};
}
}
const body: GetPackagesResponse = {
items: flattenedRes,
};

View file

@ -0,0 +1,37 @@
/*
* 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 type { SavedObjectsClientContract } from '@kbn/core/server';
import { getPackagePolicySavedObjectType } from '../package_policy';
export async function getPackagePoliciesCountByPackageName(soClient: SavedObjectsClientContract) {
const savedObjectType = await getPackagePolicySavedObjectType();
const res = await soClient.find<
any,
{ count_by_package_name: { buckets: Array<{ key: string; doc_count: number }> } }
>({
type: savedObjectType,
perPage: 0,
aggs: {
count_by_package_name: {
terms: {
field: `${savedObjectType}.attributes.package.name`,
},
},
},
});
return (
res.aggregations?.count_by_package_name.buckets.reduce((acc, bucket) => {
acc[bucket.key] = bucket.doc_count;
return acc;
}, {} as { [k: string]: number }) ?? {}
);
}

View file

@ -33,6 +33,7 @@ export const GetPackagesRequestSchema = {
category: schema.maybe(schema.string()),
prerelease: schema.maybe(schema.boolean()),
excludeInstallStatus: schema.maybe(schema.boolean({ defaultValue: false })),
withPackagePoliciesCount: schema.maybe(schema.boolean({ defaultValue: false })),
}),
};

View file

@ -10,13 +10,15 @@ import { FtrProviderContext } from '../../../api_integration/ftr_provider_contex
import { skipIfNoDockerRegistry } from '../../helpers';
import { testUsers } from '../test_users';
import { bundlePackage, removeBundledPackages } from './install_bundled';
import { SpaceTestApiClient } from '../space_awareness/api_helper';
export default function (providerContext: FtrProviderContext) {
const { getService } = providerContext;
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const esArchiver = getService('esArchiver');
const fleetAndAgents = getService('fleetAndAgents');
const kibanaServer = getService('kibanaServer');
const apiClient = new SpaceTestApiClient(supertest);
// use function () {} and not () => {} here
// because `this` has to point to the Mocha context
@ -27,11 +29,11 @@ export default function (providerContext: FtrProviderContext) {
const log = getService('log');
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/fleet/empty_fleet_server');
await kibanaServer.savedObjects.cleanStandardList();
await fleetAndAgents.setup();
});
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server');
await kibanaServer.savedObjects.cleanStandardList();
await removeBundledPackages(log);
});
@ -62,6 +64,37 @@ export default function (providerContext: FtrProviderContext) {
expect(listResponse.items.sort()).to.eql(['endpoint'].sort());
});
it('Allow to retrieve package policies count', async function () {
await apiClient.installPackage({ pkgName: 'nginx', force: true, pkgVersion: '1.20.0' });
await apiClient.createPackagePolicy(undefined, {
policy_ids: [],
name: `test-nginx-${Date.now()}`,
description: 'test',
package: {
name: 'nginx',
version: '1.20.0',
},
inputs: {},
});
const fetchPackageList = async () => {
const response = await supertest
.get('/api/fleet/epm/packages?withPackagePoliciesCount=true')
.set('kbn-xsrf', 'xxx')
.expect(200);
return response.body;
};
const listResponse = await fetchPackageList();
expect(listResponse.items.length).not.to.be(0);
for (const item of listResponse.items) {
if (item.name === 'nginx') {
expect(item.packagePoliciesInfo.count).eql(1);
} else {
expect(item.packagePoliciesInfo.count).eql(0);
}
}
});
it('allows user with only fleet permission to access', async () => {
await supertestWithoutAuth
.get('/api/fleet/epm/packages')