mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Fleet] Tabular UI for installed integrations (#212582)
This commit is contained in:
parent
1e3bb05734
commit
680bf587df
26 changed files with 1120 additions and 31 deletions
|
@ -22300,6 +22300,14 @@
|
|||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "withPackagePoliciesCount",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
|
|
@ -22300,6 +22300,14 @@
|
|||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "withPackagePoliciesCount",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
|
|
@ -22139,6 +22139,11 @@ paths:
|
|||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
- in: query
|
||||
name: withPackagePoliciesCount
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
|
|
|
@ -24226,6 +24226,11 @@ paths:
|
|||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
- in: query
|
||||
name: withPackagePoliciesCount
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
|
|
|
@ -13,6 +13,7 @@ const _allowedExperimentalValues = {
|
|||
enableAutomaticAgentUpgrades: false,
|
||||
enableSyncIntegrationsOnRemote: false,
|
||||
enableSSLSecrets: false,
|
||||
installedIntegrationsTabularUI: false,
|
||||
enabledUpgradeAgentlessDeploymentsTask: false,
|
||||
};
|
||||
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -39,6 +39,7 @@ export interface GetPackagesRequest {
|
|||
category?: string;
|
||||
prerelease?: boolean;
|
||||
excludeInstallStatus?: boolean;
|
||||
withPackagePoliciesCount?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
@ -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[] } => {
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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: () => {},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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' })],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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]);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
) => {
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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 }) ?? {}
|
||||
);
|
||||
}
|
|
@ -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 })),
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue