[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:
Julia Bardi 2025-04-22 10:58:17 +02:00 committed by GitHub
parent 6356f2cdf1
commit ea855c8dba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 843 additions and 61 deletions

View file

@ -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/,

View file

@ -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 = {

View file

@ -28,5 +28,5 @@ export interface RemoteSyncedCustomAssetsStatus extends BaseCustomAssetsData {
}
export interface RemoteSyncedCustomAssetsRecord {
[key: string]: RemoteSyncedCustomAssetsStatus | string;
[key: string]: RemoteSyncedCustomAssetsStatus;
}

View file

@ -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" />;
};

View file

@ -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>
);
});

View file

@ -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];
};

View file

@ -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();
});
});

View file

@ -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>
);
}
);

View file

@ -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}
/>
)}
</>
);
});

View file

@ -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,
}
);
}

View file

@ -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;
}
};

View file

@ -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: {

View file

@ -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: [] };
}
};

View file

@ -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',
});
});
});

View file

@ -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;
}

View file

@ -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}`
);
}