mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -04:00
[Fleet] Sync integration status UI (#218389)
## Summary Closes https://github.com/elastic/kibana/issues/217154 To test locally: - Follow this guide to set up 2 clusters locally: https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/fleet/dev_docs/local_setup/remote_clusters_ccr.md - Install a few integrations on the main cluster and create a few custom component templates and ingest pipelines - Go to Fleet Settings, check the Sync status in the Output table ### Screenshots Output table <img width="1096" alt="image" src="https://github.com/user-attachments/assets/047b516a-b32a-4827-a943-de1119d45dbe" /> Sync integrations status flyout - Added mock response to show the different UI states - Added `Close` button instead of `Cancel` and `Done` because there is no action to take on the flyout, it seemed unnecessary <img width="598" alt="image" src="https://github.com/user-attachments/assets/7cc70721-a765-488b-8191-a8a0aaefe4a1" /> Tooltips <img width="532" alt="image" src="https://github.com/user-attachments/assets/387cdf84-e807-4287-8802-4a512c756a3a" /> <img width="343" alt="image" src="https://github.com/user-attachments/assets/3c947361-5de8-40c2-bab8-a73e6321e9a8" /> Top level error without any integrations <img width="605" alt="image" src="https://github.com/user-attachments/assets/78d9a79a-fac5-4af2-9745-46dbdbe956a2" /> ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios
This commit is contained in:
parent
6356f2cdf1
commit
ea855c8dba
16 changed files with 843 additions and 61 deletions
|
@ -79,6 +79,8 @@ module.exports = {
|
|||
/x-pack[\/\\]platform[\/\\]plugins[\/\\]shared[\/\\]fleet[\/\\]public[\/\\]applications[\/\\]fleet[\/\\]sections[\/\\]settings[\/\\]components[\/\\]fleet_server_hosts_table[\/\\]index.tsx/,
|
||||
/x-pack[\/\\]platform[\/\\]plugins[\/\\]shared[\/\\]fleet[\/\\]public[\/\\]applications[\/\\]fleet[\/\\]sections[\/\\]settings[\/\\]components[\/\\]multi_row_input[\/\\]index.tsx/,
|
||||
/x-pack[\/\\]platform[\/\\]plugins[\/\\]shared[\/\\]fleet[\/\\]public[\/\\]applications[\/\\]fleet[\/\\]sections[\/\\]settings[\/\\]components[\/\\]outputs_table[\/\\]index.tsx/,
|
||||
/x-pack[\/\\]platform[\/\\]plugins[\/\\]shared[\/\\]fleet[\/\\]public[\/\\]applications[\/\\]fleet[\/\\]sections[\/\\]settings[\/\\]components[\/\\]outputs_table[\/\\]integration_status.tsx/,
|
||||
/x-pack[\/\\]platform[\/\\]plugins[\/\\]shared[\/\\]fleet[\/\\]public[\/\\]applications[\/\\]fleet[\/\\]sections[\/\\]settings[\/\\]components[\/\\]outputs_table[\/\\]integration_sync_flyout.test.tsx/,
|
||||
/x-pack[\/\\]platform[\/\\]plugins[\/\\]shared[\/\\]fleet[\/\\]public[\/\\]applications[\/\\]integrations[\/\\]sections[\/\\]epm[\/\\]components[\/\\]integration_preference.tsx/,
|
||||
/x-pack[\/\\]platform[\/\\]plugins[\/\\]shared[\/\\]fleet[\/\\]public[\/\\]applications[\/\\]integrations[\/\\]sections[\/\\]epm[\/\\]components[\/\\]package_list_grid[\/\\]controls.tsx/,
|
||||
/x-pack[\/\\]platform[\/\\]plugins[\/\\]shared[\/\\]fleet[\/\\]public[\/\\]applications[\/\\]integrations[\/\\]sections[\/\\]epm[\/\\]components[\/\\]package_list_grid[\/\\]index.tsx/,
|
||||
|
|
|
@ -25,6 +25,7 @@ import {
|
|||
FLEET_PROXY_API_ROUTES,
|
||||
UNINSTALL_TOKEN_ROUTES,
|
||||
FLEET_DEBUG_ROUTES,
|
||||
REMOTE_SYNCED_INTEGRATIONS_API_ROUTES,
|
||||
} from '../constants';
|
||||
|
||||
export const epmRouteService = {
|
||||
|
@ -313,6 +314,8 @@ export const outputRoutesService = {
|
|||
getCreateLogstashApiKeyPath: () => OUTPUT_API_ROUTES.LOGSTASH_API_KEY_PATTERN,
|
||||
getOutputHealthPath: (outputId: string) =>
|
||||
OUTPUT_API_ROUTES.GET_OUTPUT_HEALTH_PATTERN.replace('{outputId}', outputId),
|
||||
getRemoteSyncedIntegrationsStatusPath: (outputId: string) =>
|
||||
REMOTE_SYNCED_INTEGRATIONS_API_ROUTES.INFO_PATTERN.replace('{outputId}', outputId),
|
||||
};
|
||||
|
||||
export const fleetProxiesRoutesService = {
|
||||
|
|
|
@ -28,5 +28,5 @@ export interface RemoteSyncedCustomAssetsStatus extends BaseCustomAssetsData {
|
|||
}
|
||||
|
||||
export interface RemoteSyncedCustomAssetsRecord {
|
||||
[key: string]: RemoteSyncedCustomAssetsStatus | string;
|
||||
[key: string]: RemoteSyncedCustomAssetsStatus;
|
||||
}
|
||||
|
|
|
@ -16,7 +16,10 @@ import type { Output } from '../../../../types';
|
|||
|
||||
import { OutputHealth } from '../edit_output_flyout/output_health';
|
||||
|
||||
import { ExperimentalFeaturesService } from '../../../../services';
|
||||
|
||||
import { DefaultBadges } from './badges';
|
||||
import { IntegrationSyncStatus } from './integration_sync_status';
|
||||
|
||||
export interface OutputsTableProps {
|
||||
outputs: Output[];
|
||||
|
@ -53,6 +56,7 @@ export const OutputsTable: React.FunctionComponent<OutputsTableProps> = ({
|
|||
}) => {
|
||||
const authz = useAuthz();
|
||||
const { getHref } = useLink();
|
||||
const { enableSyncIntegrationsOnRemote } = ExperimentalFeaturesService.get();
|
||||
|
||||
const columns = useMemo((): Array<EuiBasicTableColumn<Output>> => {
|
||||
return [
|
||||
|
@ -117,6 +121,16 @@ export const OutputsTable: React.FunctionComponent<OutputsTableProps> = ({
|
|||
defaultMessage: 'Status',
|
||||
}),
|
||||
},
|
||||
...(enableSyncIntegrationsOnRemote
|
||||
? [
|
||||
{
|
||||
render: (output: Output) => <IntegrationSyncStatus output={output} />,
|
||||
name: i18n.translate('xpack.fleet.settings.outputsTable.integrationSyncColumnTitle', {
|
||||
defaultMessage: 'Integration syncing',
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
render: (output: Output) => <DefaultBadges output={output} />,
|
||||
width: '200px',
|
||||
|
@ -166,7 +180,7 @@ export const OutputsTable: React.FunctionComponent<OutputsTableProps> = ({
|
|||
}),
|
||||
},
|
||||
];
|
||||
}, [deleteOutput, getHref, authz.fleet.allSettings]);
|
||||
}, [deleteOutput, getHref, authz.fleet.allSettings, enableSyncIntegrationsOnRemote]);
|
||||
|
||||
return <EuiBasicTable columns={columns} items={outputs} data-test-subj="settingsOutputsTable" />;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,229 @@
|
|||
/*
|
||||
* 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, { memo, useEffect, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiAccordion,
|
||||
EuiTitle,
|
||||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiCallOut,
|
||||
EuiIcon,
|
||||
EuiLoadingSpinner,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import type { EuiAccordionProps } from '@elastic/eui/src/components/accordion';
|
||||
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
import type {
|
||||
GetInfoResponse,
|
||||
PackageInfo,
|
||||
RemoteSyncedCustomAssetsStatus,
|
||||
RemoteSyncedIntegrationsStatus,
|
||||
} from '../../../../../../../common/types';
|
||||
import { SyncStatus } from '../../../../../../../common/types';
|
||||
import { PackageIcon } from '../../../../../../components';
|
||||
|
||||
import { sendGetPackageInfoByKeyForRq } from '../../../../hooks';
|
||||
|
||||
import { IntegrationStatusBadge } from './integration_status_badge';
|
||||
|
||||
const StyledEuiPanel = styled(EuiPanel)`
|
||||
border: solid 1px ${(props) => props.theme.eui.euiFormBorderColor};
|
||||
box-shadow: none;
|
||||
border-radius: 6px;
|
||||
`;
|
||||
|
||||
const StyledEuiAccordion = styled(EuiAccordion)`
|
||||
.euiAccordion__button {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.euiAccordion__triggerWrapper {
|
||||
padding-left: ${(props) => props.theme.eui.euiSizeM};
|
||||
}
|
||||
|
||||
&.euiAccordion-isOpen {
|
||||
.euiAccordion__childWrapper {
|
||||
padding: ${(props) => props.theme.eui.euiSizeM};
|
||||
padding-top: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.ingest-integration-title-button {
|
||||
padding: ${(props) => props.theme.eui.euiSizeS};
|
||||
}
|
||||
|
||||
.euiTableRow:last-child .euiTableRowCell {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.euiIEFlexWrapFix {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.euiAccordion__buttonContent {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const CollapsablePanel: React.FC<{
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
title: React.ReactNode;
|
||||
'data-test-subj'?: string;
|
||||
}> = ({ id, title, children, 'data-test-subj': dataTestSubj }) => {
|
||||
const arrowProps = useMemo<EuiAccordionProps['arrowProps']>(() => {
|
||||
if (dataTestSubj) {
|
||||
return {
|
||||
'data-test-subj': `${dataTestSubj}-openCloseToggle`,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [dataTestSubj]);
|
||||
|
||||
return (
|
||||
<StyledEuiPanel paddingSize="none">
|
||||
<StyledEuiAccordion
|
||||
id={id}
|
||||
arrowDisplay="left"
|
||||
buttonClassName="ingest-integration-title-button"
|
||||
buttonContent={title}
|
||||
arrowProps={arrowProps}
|
||||
data-test-subj={dataTestSubj}
|
||||
>
|
||||
{children}
|
||||
</StyledEuiAccordion>
|
||||
</StyledEuiPanel>
|
||||
);
|
||||
};
|
||||
|
||||
export const IntegrationStatus: React.FunctionComponent<{
|
||||
integration: RemoteSyncedIntegrationsStatus;
|
||||
customAssets: RemoteSyncedCustomAssetsStatus[];
|
||||
'data-test-subj'?: string;
|
||||
}> = memo(({ integration, customAssets, 'data-test-subj': dataTestSubj }) => {
|
||||
const [packageInfo, setPackageInfo] = useState<PackageInfo | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
sendGetPackageInfoByKeyForRq(integration.package_name, integration.package_version, {
|
||||
prerelease: true,
|
||||
}).then((result: GetInfoResponse) => {
|
||||
setPackageInfo(result.item);
|
||||
});
|
||||
}, [integration.package_name, integration.package_version]);
|
||||
|
||||
return (
|
||||
<CollapsablePanel
|
||||
id={integration.package_name}
|
||||
data-test-subj={dataTestSubj}
|
||||
title={
|
||||
<EuiTitle size="xs">
|
||||
<h3>
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center" justifyContent="spaceBetween">
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<PackageIcon
|
||||
packageName={integration.package_name}
|
||||
version={integration.package_version}
|
||||
size="l"
|
||||
tryApi={true}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem className="eui-textTruncate">
|
||||
<EuiTitle size="xs">
|
||||
<p>{packageInfo?.title ?? integration.package_name}</p>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<IntegrationStatusBadge status={integration.sync_status.toUpperCase()} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
}
|
||||
>
|
||||
<>
|
||||
{integration.error && (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiCallOut
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.integrationSyncStatus.integrationErrorTitle"
|
||||
defaultMessage="Error"
|
||||
/>
|
||||
}
|
||||
color="danger"
|
||||
iconType="error"
|
||||
size="s"
|
||||
data-test-subj="integrationSyncIntegrationErrorCallout"
|
||||
>
|
||||
<EuiText size="s">{integration.error}</EuiText>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
)}
|
||||
{customAssets.map((customAsset) => {
|
||||
return (
|
||||
<EuiAccordion
|
||||
id={`${customAsset.type}:${customAsset.name}`}
|
||||
key={`${customAsset.type}:${customAsset.name}`}
|
||||
buttonContent={customAsset.name}
|
||||
data-test-subj={`${customAsset.type}:${customAsset.name}-accordion`}
|
||||
extraAction={
|
||||
customAsset.sync_status === SyncStatus.SYNCHRONIZING ? (
|
||||
<EuiLoadingSpinner size="m" />
|
||||
) : (
|
||||
<EuiIcon
|
||||
size="m"
|
||||
color={customAsset.sync_status === SyncStatus.FAILED ? 'danger' : 'success'}
|
||||
type={
|
||||
customAsset.sync_status === SyncStatus.FAILED
|
||||
? 'errorFilled'
|
||||
: 'checkInCircleFilled'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
paddingSize="none"
|
||||
>
|
||||
{customAsset.error && (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiCallOut
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.integrationSyncStatus.errorTitle"
|
||||
defaultMessage="Error"
|
||||
/>
|
||||
}
|
||||
color="danger"
|
||||
iconType="error"
|
||||
size="s"
|
||||
data-test-subj="integrationSyncAssetErrorCallout"
|
||||
>
|
||||
<EuiText size="s">{customAsset.error}</EuiText>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
)}
|
||||
</EuiAccordion>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</CollapsablePanel>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiBadge,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiIcon,
|
||||
EuiLoadingSpinner,
|
||||
EuiText,
|
||||
EuiToolTip,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
export const IntegrationStatusBadge: React.FunctionComponent<{
|
||||
status: string;
|
||||
onClick?: () => void;
|
||||
onClickAriaLabel?: string;
|
||||
}> = ({ status, onClick, onClickAriaLabel }) => {
|
||||
const onClickProps: any =
|
||||
onClick && onClickAriaLabel
|
||||
? {
|
||||
onClick,
|
||||
onClickAriaLabel,
|
||||
}
|
||||
: {};
|
||||
const IntegrationSyncStatusBadge: { [status: string]: JSX.Element | null } = {
|
||||
FAILED: (
|
||||
<EuiBadge
|
||||
color="danger"
|
||||
data-test-subj="integrationSyncFailedBadge"
|
||||
iconType="errorFilled"
|
||||
{...onClickProps}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.integrationSyncStatus.failedText"
|
||||
defaultMessage="Failed"
|
||||
/>
|
||||
</EuiBadge>
|
||||
),
|
||||
COMPLETED: (
|
||||
<EuiBadge
|
||||
color="success"
|
||||
data-test-subj="integrationSyncCompletedBadge"
|
||||
iconType="check"
|
||||
{...onClickProps}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.integrationSyncStatus.completedText"
|
||||
defaultMessage="Completed"
|
||||
/>
|
||||
</EuiBadge>
|
||||
),
|
||||
SYNCHRONIZING: (
|
||||
<EuiBadge color="hollow" data-test-subj="integrationSyncSyncingBadge" {...onClickProps}>
|
||||
<EuiFlexGroup alignItems="center" justifyContent="flexStart" gutterSize="xs">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLoadingSpinner size="s" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.integrationSyncStatus.syncingText"
|
||||
defaultMessage="Syncing..."
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiBadge>
|
||||
),
|
||||
NA: (
|
||||
<EuiFlexGroup alignItems="baseline" justifyContent="flexStart" gutterSize="xs">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText color="subdued" size="xs">
|
||||
<FormattedMessage id="xpack.fleet.integrationSyncStatus.naText" defaultMessage="N/A" />
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip
|
||||
content={i18n.translate('xpack.fleet.integrationSyncStatus.naTooltip', {
|
||||
defaultMessage: 'Integration syncing only applies to remote outputs.',
|
||||
})}
|
||||
>
|
||||
<EuiIcon type="iInCircle" color="subdued" />
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
DISABLED: (
|
||||
<EuiFlexGroup alignItems="baseline" justifyContent="flexStart" gutterSize="xs">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText color="subdued" size="xs">
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.integrationSyncStatus.disabledText"
|
||||
defaultMessage="Sync disabled"
|
||||
/>{' '}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip
|
||||
content={i18n.translate('xpack.fleet.integrationSyncStatus.disabledTooltip', {
|
||||
defaultMessage:
|
||||
'Integration syncing is disabled for this remote output. Enable it by clicking the edit icon and updating the output settings.',
|
||||
})}
|
||||
>
|
||||
<EuiIcon type="iInCircle" color="subdued" />
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
};
|
||||
|
||||
return IntegrationSyncStatusBadge[status];
|
||||
};
|
|
@ -0,0 +1,140 @@
|
|||
/*
|
||||
* 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 { startCase } from 'lodash';
|
||||
import { render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
|
||||
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
|
||||
import {
|
||||
SyncStatus,
|
||||
type GetRemoteSyncedIntegrationsStatusResponse,
|
||||
} from '../../../../../../../common/types';
|
||||
import { sendGetPackageInfoByKeyForRq } from '../../../../hooks';
|
||||
|
||||
import { IntegrationSyncFlyout } from './integration_sync_flyout';
|
||||
|
||||
jest.mock('../../../../hooks');
|
||||
jest.mock('../../../../../../components', () => ({
|
||||
PackageIcon: () => <div data-test-subj="packageIcon" />,
|
||||
}));
|
||||
|
||||
const mockSendGetPackageInfoByKeyForRq = sendGetPackageInfoByKeyForRq as jest.Mock;
|
||||
|
||||
describe('IntegrationSyncFlyout', () => {
|
||||
const mockOnClose = jest.fn();
|
||||
const mockSyncedIntegrationsStatus: GetRemoteSyncedIntegrationsStatusResponse = {
|
||||
integrations: [
|
||||
{
|
||||
package_name: 'elastic_agent',
|
||||
package_version: '2.2.1',
|
||||
sync_status: SyncStatus.COMPLETED,
|
||||
updated_at: '2025-04-14T11:53:00.925Z',
|
||||
},
|
||||
{
|
||||
package_name: 'nginx',
|
||||
package_version: '1.25.1',
|
||||
sync_status: SyncStatus.FAILED,
|
||||
updated_at: '2025-04-14T11:53:00.925Z',
|
||||
error: 'Nginx failed to install',
|
||||
},
|
||||
{
|
||||
package_name: 'system',
|
||||
package_version: '1.68.0',
|
||||
sync_status: SyncStatus.SYNCHRONIZING,
|
||||
updated_at: '2025-04-14T11:53:04.106Z',
|
||||
},
|
||||
],
|
||||
custom_assets: {
|
||||
'component_template:logs-system.auth@custom': {
|
||||
name: 'logs-system.auth@custom',
|
||||
type: 'component_template',
|
||||
package_name: 'system',
|
||||
package_version: '1.68.0',
|
||||
sync_status: SyncStatus.SYNCHRONIZING,
|
||||
},
|
||||
'component_template:logs-system.cpu@custom': {
|
||||
name: 'logs-system.cpu@custom',
|
||||
type: 'component_template',
|
||||
package_name: 'system',
|
||||
package_version: '1.68.0',
|
||||
sync_status: SyncStatus.COMPLETED,
|
||||
},
|
||||
'component_template:logs-nginx.auth@custom': {
|
||||
name: 'logs-nginx.auth@custom',
|
||||
type: 'component_template',
|
||||
package_name: 'nginx',
|
||||
package_version: '1.25.1',
|
||||
sync_status: SyncStatus.FAILED,
|
||||
error: 'logs-nginx.auth@custom failed to sync',
|
||||
},
|
||||
},
|
||||
error: 'Top level error message',
|
||||
};
|
||||
|
||||
const renderComponent = () => {
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<ThemeProvider
|
||||
theme={() => ({
|
||||
eui: { euiSizeS: '15px', euiSizeM: '20px', euiFormBorderColor: '#FFFFFF' },
|
||||
})}
|
||||
>
|
||||
<IntegrationSyncFlyout
|
||||
onClose={mockOnClose}
|
||||
outputName="output1"
|
||||
syncedIntegrationsStatus={mockSyncedIntegrationsStatus}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</IntlProvider>
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockSendGetPackageInfoByKeyForRq.mockImplementation((packageName) =>
|
||||
Promise.resolve({
|
||||
title: startCase(packageName),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('render accordion per integration', async () => {
|
||||
const component = renderComponent();
|
||||
expect(component.getByTestId('integrationSyncFlyoutHeaderText').textContent).toContain(
|
||||
`You're viewing sync activity for output1.`
|
||||
);
|
||||
expect(component.getByTestId('integrationSyncFlyoutTopErrorCallout').textContent).toEqual(
|
||||
'ErrorTop level error message'
|
||||
);
|
||||
|
||||
expect(component.getByTestId('elastic_agent-accordion').textContent).toContain('Completed');
|
||||
expect(component.getByTestId('nginx-accordion').textContent).toContain('Failed');
|
||||
expect(component.getByTestId('system-accordion').textContent).toContain('Syncing...');
|
||||
|
||||
await userEvent.click(component.getByTestId('nginx-accordion-openCloseToggle'));
|
||||
expect(component.getByTestId('integrationSyncIntegrationErrorCallout').textContent).toEqual(
|
||||
'ErrorNginx failed to install'
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
component.getByTestId('component_template:logs-nginx.auth@custom-accordion')
|
||||
);
|
||||
expect(component.getByTestId('integrationSyncAssetErrorCallout').textContent).toEqual(
|
||||
'Errorlogs-nginx.auth@custom failed to sync'
|
||||
);
|
||||
|
||||
expect(
|
||||
component.getByTestId('component_template:logs-system.auth@custom-accordion')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
component.getByTestId('component_template:logs-system.cpu@custom-accordion')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,108 @@
|
|||
/*
|
||||
* 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, { memo } from 'react';
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiCallOut,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutFooter,
|
||||
EuiFlyoutHeader,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
import type { GetRemoteSyncedIntegrationsStatusResponse } from '../../../../../../../common/types';
|
||||
|
||||
import { IntegrationStatus } from './integration_status';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
syncedIntegrationsStatus?: GetRemoteSyncedIntegrationsStatusResponse;
|
||||
outputName: string;
|
||||
}
|
||||
|
||||
export const IntegrationSyncFlyout: React.FunctionComponent<Props> = memo(
|
||||
({ onClose, syncedIntegrationsStatus, outputName }) => {
|
||||
return (
|
||||
<EuiFlyout onClose={onClose}>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.integrationSyncFlyout.titleText"
|
||||
defaultMessage="Integration syncing status"
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiText color="subdued" size="s" data-test-subj="integrationSyncFlyoutHeaderText">
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.integrationSyncFlyout.headerText"
|
||||
defaultMessage="You're viewing sync activity for {outputName}. Check overall progress and view individual sync statuses from custom assets."
|
||||
values={{
|
||||
outputName,
|
||||
}}
|
||||
/>
|
||||
</EuiText>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
{syncedIntegrationsStatus?.error && (
|
||||
<EuiCallOut
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.integrationSyncFlyout.errorTitle"
|
||||
defaultMessage="Error"
|
||||
/>
|
||||
}
|
||||
color="danger"
|
||||
iconType="error"
|
||||
size="s"
|
||||
data-test-subj="integrationSyncFlyoutTopErrorCallout"
|
||||
>
|
||||
<EuiText size="s">{syncedIntegrationsStatus?.error}</EuiText>
|
||||
</EuiCallOut>
|
||||
)}
|
||||
<EuiFlexGroup direction="column" gutterSize="m">
|
||||
{(syncedIntegrationsStatus?.integrations ?? []).map((integration) => {
|
||||
const customAssets = Object.values(
|
||||
syncedIntegrationsStatus?.custom_assets ?? {}
|
||||
).filter((asset) => asset.package_name === integration.package_name);
|
||||
return (
|
||||
<EuiFlexItem grow={false} key={integration.package_name}>
|
||||
<IntegrationStatus
|
||||
data-test-subj={`${integration.package_name}-accordion`}
|
||||
integration={integration}
|
||||
customAssets={customAssets}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
})}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="flexStart">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty onClick={onClose}>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.integrationSyncFlyout.closeFlyoutButtonLabel"
|
||||
defaultMessage="Close"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* 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, { memo, useMemo, useState } from 'react';
|
||||
|
||||
import { type Output } from '../../../../../../../common/types';
|
||||
|
||||
import { useGetRemoteSyncedIntegrationsStatusQuery } from '../../../../hooks';
|
||||
|
||||
import { IntegrationSyncFlyout } from './integration_sync_flyout';
|
||||
import { IntegrationStatusBadge } from './integration_status_badge';
|
||||
|
||||
interface Props {
|
||||
output: Output;
|
||||
}
|
||||
|
||||
export const IntegrationSyncStatus: React.FunctionComponent<Props> = memo(({ output }) => {
|
||||
const { data: syncedIntegrationsStatus, error } = useGetRemoteSyncedIntegrationsStatusQuery(
|
||||
output.id,
|
||||
{ enabled: output.type === 'remote_elasticsearch' && output.sync_integrations }
|
||||
);
|
||||
|
||||
const [showStatusFlyout, setShowStatusFlyout] = useState(false);
|
||||
|
||||
const status = useMemo(() => {
|
||||
if (output.type !== 'remote_elasticsearch') {
|
||||
return 'NA';
|
||||
}
|
||||
if (!output.sync_integrations) {
|
||||
return 'DISABLED';
|
||||
}
|
||||
if (!error && !syncedIntegrationsStatus) {
|
||||
return 'SYNCHRONIZING';
|
||||
}
|
||||
|
||||
const syncCompleted =
|
||||
syncedIntegrationsStatus?.integrations.every(
|
||||
(integration) => integration.sync_status === 'completed'
|
||||
) &&
|
||||
Object.values(syncedIntegrationsStatus?.custom_assets ?? {}).every(
|
||||
(asset) => asset.sync_status === 'completed'
|
||||
);
|
||||
|
||||
const newStatus =
|
||||
(error as any)?.message || syncedIntegrationsStatus?.error
|
||||
? 'FAILED'
|
||||
: syncCompleted
|
||||
? 'COMPLETED'
|
||||
: 'SYNCHRONIZING';
|
||||
return newStatus;
|
||||
}, [output, syncedIntegrationsStatus, error]);
|
||||
|
||||
const onClick = () => {
|
||||
setShowStatusFlyout(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
<IntegrationStatusBadge
|
||||
status={status}
|
||||
onClick={onClick}
|
||||
onClickAriaLabel={'Show details'}
|
||||
/>
|
||||
}
|
||||
{showStatusFlyout && (
|
||||
<IntegrationSyncFlyout
|
||||
onClose={() => setShowStatusFlyout(false)}
|
||||
syncedIntegrationsStatus={
|
||||
error
|
||||
? {
|
||||
integrations: [],
|
||||
error: (error as any).message,
|
||||
}
|
||||
: syncedIntegrationsStatus
|
||||
}
|
||||
outputName={output.name}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
|
@ -5,7 +5,12 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { GetOutputHealthResponse } from '../../../common/types';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type {
|
||||
GetOutputHealthResponse,
|
||||
GetRemoteSyncedIntegrationsStatusResponse,
|
||||
} from '../../../common/types';
|
||||
|
||||
import { outputRoutesService } from '../../services';
|
||||
import type {
|
||||
|
@ -17,7 +22,7 @@ import type {
|
|||
|
||||
import { API_VERSIONS } from '../../../common/constants';
|
||||
|
||||
import { sendRequest, useRequest } from './use_request';
|
||||
import { sendRequest, sendRequestForRq, useRequest } from './use_request';
|
||||
|
||||
export function useGetOutputs() {
|
||||
return useRequest<GetOutputsResponse>({
|
||||
|
@ -75,3 +80,28 @@ export function sendGetOutputHealth(outputId: string) {
|
|||
version: API_VERSIONS.public.v1,
|
||||
});
|
||||
}
|
||||
|
||||
export function sendGetRemoteSyncedIntegrationsStatus(outputId: string) {
|
||||
return sendRequestForRq<GetRemoteSyncedIntegrationsStatusResponse>({
|
||||
method: 'get',
|
||||
path: outputRoutesService.getRemoteSyncedIntegrationsStatusPath(outputId),
|
||||
version: API_VERSIONS.public.v1,
|
||||
});
|
||||
}
|
||||
|
||||
const SYNC_STATUS_REFETCH_INTERVAL = 10000;
|
||||
|
||||
export function useGetRemoteSyncedIntegrationsStatusQuery(
|
||||
outputId: string,
|
||||
options: Partial<{ enabled: boolean }> = {}
|
||||
) {
|
||||
return useQuery(
|
||||
[`remote_synced_integrations_status_${outputId}`],
|
||||
() => sendGetRemoteSyncedIntegrationsStatus(outputId),
|
||||
{
|
||||
enabled: options.enabled,
|
||||
refetchInterval: SYNC_STATUS_REFETCH_INTERVAL,
|
||||
retry: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -42,14 +42,8 @@ export const getRemoteSyncedIntegrationsInfoHandler: RequestHandler<
|
|||
try {
|
||||
const res: GetRemoteSyncedIntegrationsStatusResponse =
|
||||
await getRemoteSyncedIntegrationsInfoByOutputId(soClient, request.params.outputId);
|
||||
|
||||
return response.ok({ body: res });
|
||||
} catch (error) {
|
||||
if (error.isBoom && error.output.statusCode === 404) {
|
||||
return response.notFound({
|
||||
body: { message: `${request.params.outputId} not found` },
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -196,11 +196,19 @@ describe('fetchAndCompareSyncedIntegrations', () => {
|
|||
package_name: 'elastic_agent',
|
||||
package_version: '2.2.0',
|
||||
updated_at: '2025-03-20T14:18:40.076Z',
|
||||
install_status: 'installed',
|
||||
},
|
||||
{
|
||||
package_name: 'system',
|
||||
package_version: '1.67.3',
|
||||
updated_at: '2025-03-20T14:18:40.111Z',
|
||||
install_status: 'installed',
|
||||
},
|
||||
{
|
||||
package_name: 'fleet_server',
|
||||
package_version: '1.67.3',
|
||||
updated_at: '2025-03-20T14:18:40.111Z',
|
||||
install_status: 'not_installed',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -242,11 +250,13 @@ describe('fetchAndCompareSyncedIntegrations', () => {
|
|||
package_name: 'elastic_agent',
|
||||
package_version: '2.2.0',
|
||||
updated_at: '2025-03-20T14:18:40.076Z',
|
||||
install_status: 'installed',
|
||||
},
|
||||
{
|
||||
package_name: 'system',
|
||||
package_version: '1.67.3',
|
||||
updated_at: '2025-03-20T14:18:40.111Z',
|
||||
install_status: 'installed',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -316,16 +326,19 @@ describe('fetchAndCompareSyncedIntegrations', () => {
|
|||
package_name: 'elastic_agent',
|
||||
package_version: '2.2.0',
|
||||
updated_at: '2025-03-20T14:18:40.076Z',
|
||||
install_status: 'installed',
|
||||
},
|
||||
{
|
||||
package_name: 'system',
|
||||
package_version: '1.67.3',
|
||||
updated_at: '2025-03-20T14:18:40.111Z',
|
||||
install_status: 'installed',
|
||||
},
|
||||
{
|
||||
package_name: 'synthetics',
|
||||
package_version: '1.4.1',
|
||||
updated_at: '2025-03-17T15:21:14.092Z',
|
||||
install_status: 'installed',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -361,7 +374,7 @@ describe('fetchAndCompareSyncedIntegrations', () => {
|
|||
id: 'synthetics',
|
||||
attributes: {
|
||||
version: '1.4.1',
|
||||
install_status: 'not_installed',
|
||||
install_status: 'install_failed',
|
||||
latest_install_failed_attempts: [
|
||||
{
|
||||
created_at: '2023-06-20T08:47:31.457Z',
|
||||
|
@ -397,7 +410,7 @@ describe('fetchAndCompareSyncedIntegrations', () => {
|
|||
error: 'Found incorrect installed version 1.67.2',
|
||||
},
|
||||
{
|
||||
error: `Installation status: not_installed - reason: installation failure at Tue, 20 Jun 2023 08:47:31 GMT`,
|
||||
error: `Installation status: install_failed - reason: installation failure at Tue, 20 Jun 2023 08:47:31 GMT`,
|
||||
package_name: 'synthetics',
|
||||
package_version: '1.4.1',
|
||||
sync_status: 'failed',
|
||||
|
@ -468,6 +481,7 @@ describe('fetchAndCompareSyncedIntegrations', () => {
|
|||
package_name: 'system',
|
||||
package_version: '1.67.3',
|
||||
updated_at: '2025-03-20T14:18:40.076Z',
|
||||
install_status: 'installed',
|
||||
},
|
||||
],
|
||||
custom_assets: {
|
||||
|
@ -685,6 +699,7 @@ describe('fetchAndCompareSyncedIntegrations', () => {
|
|||
package_name: 'system',
|
||||
package_version: '1.67.3',
|
||||
updated_at: '2025-03-20T14:18:40.076Z',
|
||||
install_status: 'installed',
|
||||
},
|
||||
],
|
||||
custom_assets: {
|
||||
|
@ -885,6 +900,7 @@ describe('fetchAndCompareSyncedIntegrations', () => {
|
|||
package_name: 'system',
|
||||
package_version: '1.67.3',
|
||||
updated_at: '2025-03-20T14:18:40.076Z',
|
||||
install_status: 'installed',
|
||||
},
|
||||
],
|
||||
custom_assets: {
|
||||
|
@ -967,6 +983,7 @@ describe('fetchAndCompareSyncedIntegrations', () => {
|
|||
package_name: 'system',
|
||||
package_version: '1.67.3',
|
||||
updated_at: '2025-03-20T14:18:40.076Z',
|
||||
install_status: 'installed',
|
||||
},
|
||||
],
|
||||
custom_assets: {
|
||||
|
@ -1080,6 +1097,7 @@ describe('fetchAndCompareSyncedIntegrations', () => {
|
|||
package_name: 'system',
|
||||
package_version: '1.67.3',
|
||||
updated_at: '2025-03-20T14:18:40.076Z',
|
||||
install_status: 'installed',
|
||||
},
|
||||
],
|
||||
custom_assets: {
|
||||
|
|
|
@ -29,10 +29,10 @@ import type { Installation } from '../../types';
|
|||
import type {
|
||||
RemoteSyncedIntegrationsStatus,
|
||||
GetRemoteSyncedIntegrationsStatusResponse,
|
||||
SyncStatus,
|
||||
RemoteSyncedCustomAssetsStatus,
|
||||
RemoteSyncedCustomAssetsRecord,
|
||||
} from '../../../common/types';
|
||||
import { SyncStatus } from '../../../common/types';
|
||||
|
||||
import type { IntegrationsData, SyncIntegrationsData, CustomAssetsData } from './model';
|
||||
import { getPipeline, getComponentTemplate, CUSTOM_ASSETS_PREFIX } from './custom_assets';
|
||||
|
@ -93,6 +93,9 @@ export const fetchAndCompareSyncedIntegrations = async (
|
|||
}
|
||||
const ccrIndex = searchRes.hits.hits[0]?._source;
|
||||
const { integrations: ccrIntegrations, custom_assets: ccrCustomAssets } = ccrIndex;
|
||||
const installedCCRIntegrations = ccrIntegrations?.filter(
|
||||
(integration) => integration.install_status !== 'not_installed'
|
||||
);
|
||||
|
||||
// find integrations installed on remote
|
||||
const installedIntegrations = await getPackageSavedObjects(savedObjectsClient);
|
||||
|
@ -107,7 +110,10 @@ export const fetchAndCompareSyncedIntegrations = async (
|
|||
{} as Record<string, SavedObjectsFindResult<Installation>>
|
||||
);
|
||||
const customAssetsStatus = await fetchAndCompareCustomAssets(esClient, logger, ccrCustomAssets);
|
||||
const integrationsStatus = compareIntegrations(ccrIntegrations, installedIntegrationsByName);
|
||||
const integrationsStatus = compareIntegrations(
|
||||
installedCCRIntegrations,
|
||||
installedIntegrationsByName
|
||||
);
|
||||
const result = {
|
||||
...integrationsStatus,
|
||||
...(customAssetsStatus && { custom_assets: customAssetsStatus }),
|
||||
|
@ -135,7 +141,7 @@ const compareIntegrations = (
|
|||
package_name: ccrIntegration.package_name,
|
||||
package_version: ccrIntegration.package_version,
|
||||
updated_at: ccrIntegration.updated_at,
|
||||
sync_status: 'synchronizing' as SyncStatus.SYNCHRONIZING,
|
||||
sync_status: SyncStatus.SYNCHRONIZING,
|
||||
};
|
||||
}
|
||||
if (ccrIntegration.package_version !== localIntegrationSO?.attributes.version) {
|
||||
|
@ -143,11 +149,11 @@ const compareIntegrations = (
|
|||
package_name: ccrIntegration.package_name,
|
||||
package_version: ccrIntegration.package_version,
|
||||
updated_at: ccrIntegration.updated_at,
|
||||
sync_status: 'failed' as SyncStatus.FAILED,
|
||||
sync_status: SyncStatus.FAILED,
|
||||
error: `Found incorrect installed version ${localIntegrationSO?.attributes.version}`,
|
||||
};
|
||||
}
|
||||
if (localIntegrationSO?.attributes.install_status !== 'installed') {
|
||||
if (localIntegrationSO?.attributes.install_status === 'install_failed') {
|
||||
const latestFailedAttemptTime = localIntegrationSO?.attributes
|
||||
?.latest_install_failed_attempts?.[0].created_at
|
||||
? `at ${new Date(
|
||||
|
@ -162,14 +168,17 @@ const compareIntegrations = (
|
|||
package_name: ccrIntegration.package_name,
|
||||
package_version: ccrIntegration.package_version,
|
||||
updated_at: ccrIntegration.updated_at,
|
||||
sync_status: 'failed' as SyncStatus.FAILED,
|
||||
sync_status: SyncStatus.FAILED,
|
||||
error: `Installation status: ${localIntegrationSO?.attributes.install_status} ${latestFailedAttempt} ${latestFailedAttemptTime}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
package_name: ccrIntegration.package_name,
|
||||
package_version: ccrIntegration.package_version,
|
||||
sync_status: 'completed' as SyncStatus.COMPLETED,
|
||||
sync_status:
|
||||
localIntegrationSO?.attributes.install_status === 'installed'
|
||||
? SyncStatus.COMPLETED
|
||||
: SyncStatus.SYNCHRONIZING,
|
||||
updated_at: localIntegrationSO?.updated_at,
|
||||
};
|
||||
}
|
||||
|
@ -254,12 +263,12 @@ const compareCustomAssets = ({
|
|||
if (ccrCustomAsset.is_deleted === true) {
|
||||
return {
|
||||
...result,
|
||||
sync_status: 'completed' as SyncStatus.COMPLETED,
|
||||
sync_status: SyncStatus.COMPLETED,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
sync_status: 'synchronizing' as SyncStatus.SYNCHRONIZING,
|
||||
sync_status: SyncStatus.SYNCHRONIZING,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -267,7 +276,7 @@ const compareCustomAssets = ({
|
|||
if (ccrCustomAsset.is_deleted === true && installedPipeline) {
|
||||
return {
|
||||
...result,
|
||||
sync_status: 'synchronizing' as SyncStatus.SYNCHRONIZING,
|
||||
sync_status: SyncStatus.SYNCHRONIZING,
|
||||
};
|
||||
} else if (
|
||||
installedPipeline?.version &&
|
||||
|
@ -275,17 +284,17 @@ const compareCustomAssets = ({
|
|||
) {
|
||||
return {
|
||||
...result,
|
||||
sync_status: 'synchronizing' as SyncStatus.SYNCHRONIZING,
|
||||
sync_status: SyncStatus.SYNCHRONIZING,
|
||||
};
|
||||
} else if (isEqual(installedPipeline, ccrCustomAsset?.pipeline)) {
|
||||
return {
|
||||
...result,
|
||||
sync_status: 'completed' as SyncStatus.COMPLETED,
|
||||
sync_status: SyncStatus.COMPLETED,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...result,
|
||||
sync_status: 'synchronizing' as SyncStatus.SYNCHRONIZING,
|
||||
sync_status: SyncStatus.SYNCHRONIZING,
|
||||
};
|
||||
}
|
||||
} else if (ccrCustomAsset.type === 'component_template') {
|
||||
|
@ -293,12 +302,12 @@ const compareCustomAssets = ({
|
|||
if (ccrCustomAsset.is_deleted === true) {
|
||||
return {
|
||||
...result,
|
||||
sync_status: 'completed' as SyncStatus.COMPLETED,
|
||||
sync_status: SyncStatus.COMPLETED,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
sync_status: 'synchronizing' as SyncStatus.SYNCHRONIZING,
|
||||
sync_status: SyncStatus.SYNCHRONIZING,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -306,17 +315,17 @@ const compareCustomAssets = ({
|
|||
if (ccrCustomAsset.is_deleted === true && installedCompTemplate) {
|
||||
return {
|
||||
...result,
|
||||
sync_status: 'synchronizing' as SyncStatus.SYNCHRONIZING,
|
||||
sync_status: SyncStatus.SYNCHRONIZING,
|
||||
};
|
||||
} else if (isEqual(installedCompTemplate, ccrCustomAsset?.template)) {
|
||||
return {
|
||||
...result,
|
||||
sync_status: 'completed' as SyncStatus.COMPLETED,
|
||||
sync_status: SyncStatus.COMPLETED,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...result,
|
||||
sync_status: 'synchronizing' as SyncStatus.SYNCHRONIZING,
|
||||
sync_status: SyncStatus.SYNCHRONIZING,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -347,6 +356,6 @@ export const getRemoteSyncedIntegrationsStatus = async (
|
|||
);
|
||||
return res;
|
||||
} catch (error) {
|
||||
return { error, integrations: [] };
|
||||
return { error: error.message, integrations: [] };
|
||||
}
|
||||
};
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
|
||||
import fetch from 'node-fetch';
|
||||
import fetch, { FetchError } from 'node-fetch';
|
||||
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
import type { Logger } from '@kbn/core/server';
|
||||
|
@ -86,25 +86,11 @@ describe('getRemoteSyncedIntegrationsInfoByOutputId', () => {
|
|||
jest
|
||||
.spyOn(mockedAppContextService, 'getExperimentalFeatures')
|
||||
.mockReturnValue({ enableSyncIntegrationsOnRemote: true } as any);
|
||||
mockedOutputService.get.mockImplementation(() => {
|
||||
throw new FleetNotFoundError(
|
||||
'Saved object [ingest-outputs/remote-es-not-existent] not found'
|
||||
);
|
||||
});
|
||||
mockedOutputService.get.mockRejectedValue({ isBoom: true, output: { statusCode: 404 } } as any);
|
||||
|
||||
await expect(
|
||||
getRemoteSyncedIntegrationsInfoByOutputId(soClientMock, 'remote-es-not-existent')
|
||||
).rejects.toThrowError();
|
||||
});
|
||||
|
||||
it('should throw error if the output returns undefined', async () => {
|
||||
jest
|
||||
.spyOn(mockedAppContextService, 'getExperimentalFeatures')
|
||||
.mockReturnValue({ enableSyncIntegrationsOnRemote: true } as any);
|
||||
mockedOutputService.get.mockResolvedValue(undefined as any);
|
||||
await expect(
|
||||
getRemoteSyncedIntegrationsInfoByOutputId(soClientMock, 'remote2')
|
||||
).rejects.toThrowError('No output found with id remote2');
|
||||
).rejects.toThrowError('No output found with id remote-es-not-existent');
|
||||
});
|
||||
|
||||
it('should throw error if the passed outputId is not of type remote_elasticsearch', async () => {
|
||||
|
@ -222,8 +208,9 @@ describe('getRemoteSyncedIntegrationsInfoByOutputId', () => {
|
|||
kibana_api_key: 'APIKEY',
|
||||
} as any);
|
||||
const statusWithErrorRes = {
|
||||
error: 'No integrations found on fleet-synced-integrations-ccr-*',
|
||||
integrations: [],
|
||||
statusCode: 404,
|
||||
error: 'Not Found',
|
||||
message: 'No integrations found on fleet-synced-integrations-ccr-*',
|
||||
};
|
||||
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
|
@ -232,9 +219,11 @@ describe('getRemoteSyncedIntegrationsInfoByOutputId', () => {
|
|||
ok: true,
|
||||
} as any);
|
||||
|
||||
expect(await getRemoteSyncedIntegrationsInfoByOutputId(soClientMock, 'remote1')).toEqual(
|
||||
statusWithErrorRes
|
||||
);
|
||||
expect(await getRemoteSyncedIntegrationsInfoByOutputId(soClientMock, 'remote1')).toEqual({
|
||||
integrations: [],
|
||||
error:
|
||||
'GET http://remote-kibana-host/api/fleet/remote_synced_integrations/status failed with status 404. No integrations found on fleet-synced-integrations-ccr-*',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw if the remote api returns error', async () => {
|
||||
|
@ -255,4 +244,32 @@ describe('getRemoteSyncedIntegrationsInfoByOutputId', () => {
|
|||
getRemoteSyncedIntegrationsInfoByOutputId(soClientMock, 'remote1')
|
||||
).rejects.toThrowError('some error');
|
||||
});
|
||||
|
||||
it('should return error if the fetch returns invalid-json error', async () => {
|
||||
jest
|
||||
.spyOn(mockedAppContextService, 'getExperimentalFeatures')
|
||||
.mockReturnValue({ enableSyncIntegrationsOnRemote: true } as any);
|
||||
mockedOutputService.get.mockResolvedValue({
|
||||
...output,
|
||||
sync_integrations: true,
|
||||
kibana_url: 'http://remote-kibana-host/invalid',
|
||||
kibana_api_key: 'APIKEY',
|
||||
} as any);
|
||||
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
json: () => {
|
||||
const err = new FetchError(`some error`, 'invalid-json');
|
||||
err.type = 'invalid-json';
|
||||
err.message = `some error`;
|
||||
throw err;
|
||||
},
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
} as any);
|
||||
expect(await getRemoteSyncedIntegrationsInfoByOutputId(soClientMock, 'remote1')).toEqual({
|
||||
integrations: [],
|
||||
error:
|
||||
'GET http://remote-kibana-host/invalid/api/fleet/remote_synced_integrations/status failed with status 404. some error',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -30,9 +30,6 @@ export const getRemoteSyncedIntegrationsInfoByOutputId = async (
|
|||
}
|
||||
try {
|
||||
const output = await outputService.get(soClient, outputId);
|
||||
if (!output) {
|
||||
throw new FleetNotFoundError(`No output found with id ${outputId}`);
|
||||
}
|
||||
if (output?.type !== 'remote_elasticsearch') {
|
||||
throw new FleetError(`Output ${outputId} is not a remote elasticsearch output`);
|
||||
}
|
||||
|
@ -63,14 +60,30 @@ export const getRemoteSyncedIntegrationsInfoByOutputId = async (
|
|||
method: 'GET',
|
||||
};
|
||||
const url = `${kibanaUrl}/api/fleet/remote_synced_integrations/status`;
|
||||
logger.info(`Fetching ${kibanaUrl}/api/fleet/remote_synced_integrations/status`);
|
||||
logger.debug(`Fetching ${kibanaUrl}/api/fleet/remote_synced_integrations/status`);
|
||||
|
||||
let body;
|
||||
let errorMessage;
|
||||
const res = await fetch(url, options);
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch (error) {
|
||||
errorMessage = `GET ${url} failed with status ${res.status}. ${error.message}`;
|
||||
}
|
||||
|
||||
const body = await res.json();
|
||||
if (body?.statusCode && body?.message) {
|
||||
errorMessage = `GET ${url} failed with status ${body.statusCode}. ${body.message}`;
|
||||
}
|
||||
|
||||
return body as GetRemoteSyncedIntegrationsStatusResponse;
|
||||
return {
|
||||
integrations: body?.integrations ?? [],
|
||||
custom_assets: body?.custom_assets,
|
||||
error: errorMessage ?? body?.error,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.isBoom && error.output.statusCode === 404) {
|
||||
throw new FleetNotFoundError(`No output found with id ${outputId}`);
|
||||
}
|
||||
logger.error(`${error}`);
|
||||
throw error;
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ import semverGte from 'semver/functions/gte';
|
|||
import type { PackageClient } from '../../services';
|
||||
import { outputService } from '../../services';
|
||||
|
||||
import { PackageNotFoundError } from '../../errors';
|
||||
import { FleetError, PackageNotFoundError } from '../../errors';
|
||||
import { FLEET_SYNCED_INTEGRATIONS_CCR_INDEX_PREFIX } from '../../services/setup/fleet_synced_integrations';
|
||||
|
||||
import { getInstallation, removeInstallation } from '../../services/epm/packages';
|
||||
|
@ -36,7 +36,7 @@ export const getFollowerIndex = async (
|
|||
|
||||
const indexNames = Object.keys(indices);
|
||||
if (indexNames.length > 1) {
|
||||
throw new Error(
|
||||
throw new FleetError(
|
||||
`Not supported to sync multiple indices with prefix ${FLEET_SYNCED_INTEGRATIONS_CCR_INDEX_PREFIX}`
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue