[Fleet] add support for fleet server urls (#94364)
This commit is contained in:
parent
d89ede9834
commit
db7da2238e
|
@ -26,6 +26,9 @@ export interface FleetConfigType {
|
|||
host?: string;
|
||||
ca_sha256?: string;
|
||||
};
|
||||
fleet_server?: {
|
||||
hosts?: string[];
|
||||
};
|
||||
agentPolicyRolloutRateLimitIntervalMs: number;
|
||||
agentPolicyRolloutRateLimitRequestPerInterval: number;
|
||||
};
|
||||
|
|
|
@ -66,9 +66,13 @@ export interface FullAgentPolicy {
|
|||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
fleet?: {
|
||||
kibana: FullAgentPolicyKibanaConfig;
|
||||
};
|
||||
fleet?:
|
||||
| {
|
||||
hosts: string[];
|
||||
}
|
||||
| {
|
||||
kibana: FullAgentPolicyKibanaConfig;
|
||||
};
|
||||
inputs: FullAgentPolicyInput[];
|
||||
revision?: number;
|
||||
agent?: {
|
||||
|
|
|
@ -8,9 +8,11 @@
|
|||
import type { SavedObjectAttributes } from 'src/core/public';
|
||||
|
||||
export interface BaseSettings {
|
||||
has_seen_add_data_notice?: boolean;
|
||||
fleet_server_hosts: string[];
|
||||
// TODO remove as part of https://github.com/elastic/kibana/issues/94303
|
||||
kibana_urls: string[];
|
||||
kibana_ca_sha256?: string;
|
||||
has_seen_add_data_notice?: boolean;
|
||||
}
|
||||
|
||||
export interface Settings extends BaseSettings {
|
||||
|
|
|
@ -13,6 +13,7 @@ import { FormattedMessage } from '@kbn/i18n/react';
|
|||
import type { EnrollmentAPIKey } from '../../../types';
|
||||
|
||||
interface Props {
|
||||
fleetServerHosts: string[];
|
||||
kibanaUrl: string;
|
||||
apiKey: EnrollmentAPIKey;
|
||||
kibanaCASha256?: string;
|
||||
|
@ -23,14 +24,32 @@ const CommandCode = styled.pre({
|
|||
overflow: 'scroll',
|
||||
});
|
||||
|
||||
function getfleetServerHostsEnrollArgs(apiKey: EnrollmentAPIKey, fleetServerHosts: string[]) {
|
||||
return `--url=${fleetServerHosts[0]} --enrollment-token=${apiKey.api_key}`;
|
||||
}
|
||||
|
||||
function getKibanaUrlEnrollArgs(
|
||||
apiKey: EnrollmentAPIKey,
|
||||
kibanaUrl: string,
|
||||
kibanaCASha256?: string
|
||||
) {
|
||||
return `--kibana-url=${kibanaUrl} --enrollment-token=${apiKey.api_key}${
|
||||
kibanaCASha256 ? ` --ca_sha256=${kibanaCASha256}` : ''
|
||||
}`;
|
||||
}
|
||||
|
||||
export const ManualInstructions: React.FunctionComponent<Props> = ({
|
||||
kibanaUrl,
|
||||
apiKey,
|
||||
kibanaCASha256,
|
||||
fleetServerHosts,
|
||||
}) => {
|
||||
const enrollArgs = `--kibana-url=${kibanaUrl} --enrollment-token=${apiKey.api_key}${
|
||||
kibanaCASha256 ? ` --ca_sha256=${kibanaCASha256}` : ''
|
||||
}`;
|
||||
const fleetServerHostsNotEmpty = fleetServerHosts.length > 0;
|
||||
|
||||
const enrollArgs = fleetServerHostsNotEmpty
|
||||
? getfleetServerHostsEnrollArgs(apiKey, fleetServerHosts)
|
||||
: // TODO remove as part of https://github.com/elastic/kibana/issues/94303
|
||||
getKibanaUrlEnrollArgs(apiKey, kibanaUrl, kibanaCASha256);
|
||||
|
||||
const linuxMacCommand = `./elastic-agent install -f ${enrollArgs}`;
|
||||
|
||||
|
|
|
@ -1,272 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutHeader,
|
||||
EuiTitle,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiButtonEmpty,
|
||||
EuiSpacer,
|
||||
EuiButton,
|
||||
EuiFlyoutFooter,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
EuiComboBox,
|
||||
EuiCodeEditor,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiText } from '@elastic/eui';
|
||||
import { safeLoad } from 'js-yaml';
|
||||
|
||||
import {
|
||||
useComboInput,
|
||||
useStartServices,
|
||||
useGetSettings,
|
||||
useInput,
|
||||
sendPutSettings,
|
||||
} from '../hooks';
|
||||
import { useGetOutputs, sendPutOutput } from '../hooks/use_request/outputs';
|
||||
import { isDiffPathProtocol } from '../../../../common/';
|
||||
|
||||
const URL_REGEX = /^(https?):\/\/[^\s$.?#].[^\s]*$/gm;
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function useSettingsForm(outputId: string | undefined, onSuccess: () => void) {
|
||||
const [isLoading, setIsloading] = React.useState(false);
|
||||
const { notifications } = useStartServices();
|
||||
const kibanaUrlsInput = useComboInput([], (value) => {
|
||||
if (value.length === 0) {
|
||||
return [
|
||||
i18n.translate('xpack.fleet.settings.kibanaUrlEmptyError', {
|
||||
defaultMessage: 'At least one URL is required',
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (value.some((v) => !v.match(URL_REGEX))) {
|
||||
return [
|
||||
i18n.translate('xpack.fleet.settings.kibanaUrlError', {
|
||||
defaultMessage: 'Invalid URL',
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (isDiffPathProtocol(value)) {
|
||||
return [
|
||||
i18n.translate('xpack.fleet.settings.kibanaUrlDifferentPathOrProtocolError', {
|
||||
defaultMessage: 'Protocol and path must be the same for each URL',
|
||||
}),
|
||||
];
|
||||
}
|
||||
});
|
||||
const elasticsearchUrlInput = useComboInput([], (value) => {
|
||||
if (value.some((v) => !v.match(URL_REGEX))) {
|
||||
return [
|
||||
i18n.translate('xpack.fleet.settings.elasticHostError', {
|
||||
defaultMessage: 'Invalid URL',
|
||||
}),
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
const additionalYamlConfigInput = useInput('', (value) => {
|
||||
try {
|
||||
safeLoad(value);
|
||||
return;
|
||||
} catch (error) {
|
||||
return [
|
||||
i18n.translate('xpack.fleet.settings.invalidYamlFormatErrorMessage', {
|
||||
defaultMessage: 'Invalid YAML: {reason}',
|
||||
values: { reason: error.message },
|
||||
}),
|
||||
];
|
||||
}
|
||||
});
|
||||
return {
|
||||
isLoading,
|
||||
onSubmit: async () => {
|
||||
if (
|
||||
!kibanaUrlsInput.validate() ||
|
||||
!elasticsearchUrlInput.validate() ||
|
||||
!additionalYamlConfigInput.validate()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsloading(true);
|
||||
if (!outputId) {
|
||||
throw new Error('Unable to load outputs');
|
||||
}
|
||||
const outputResponse = await sendPutOutput(outputId, {
|
||||
hosts: elasticsearchUrlInput.value,
|
||||
config_yaml: additionalYamlConfigInput.value,
|
||||
});
|
||||
if (outputResponse.error) {
|
||||
throw outputResponse.error;
|
||||
}
|
||||
const settingsResponse = await sendPutSettings({
|
||||
kibana_urls: kibanaUrlsInput.value,
|
||||
});
|
||||
if (settingsResponse.error) {
|
||||
throw settingsResponse.error;
|
||||
}
|
||||
notifications.toasts.addSuccess(
|
||||
i18n.translate('xpack.fleet.settings.success.message', {
|
||||
defaultMessage: 'Settings saved',
|
||||
})
|
||||
);
|
||||
setIsloading(false);
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
setIsloading(false);
|
||||
notifications.toasts.addError(error, {
|
||||
title: 'Error',
|
||||
});
|
||||
}
|
||||
},
|
||||
inputs: {
|
||||
kibanaUrls: kibanaUrlsInput,
|
||||
elasticsearchUrl: elasticsearchUrlInput,
|
||||
additionalYamlConfig: additionalYamlConfigInput,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const SettingFlyout: React.FunctionComponent<Props> = ({ onClose }) => {
|
||||
const settingsRequest = useGetSettings();
|
||||
const settings = settingsRequest?.data?.item;
|
||||
const outputsRequest = useGetOutputs();
|
||||
const output = outputsRequest.data?.items?.[0];
|
||||
const { inputs, onSubmit, isLoading } = useSettingsForm(output?.id, onClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (output) {
|
||||
inputs.elasticsearchUrl.setValue(output.hosts || []);
|
||||
inputs.additionalYamlConfig.setValue(
|
||||
output.config_yaml ||
|
||||
`# YAML settings here will be added to the Elasticsearch output section of each policy`
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [output]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
inputs.kibanaUrls.setValue(settings.kibana_urls);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings]);
|
||||
|
||||
const body = (
|
||||
<EuiForm>
|
||||
<EuiTitle size="s">
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settings.globalOutputTitle"
|
||||
defaultMessage="Global output"
|
||||
/>
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiText color="subdued" size="s">
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settings.globalOutputDescription"
|
||||
defaultMessage="Specify where to send data. These settings are applied to all Elastic Agent policies."
|
||||
/>
|
||||
</EuiText>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.fleet.settings.kibanaUrlLabel', {
|
||||
defaultMessage: 'Kibana URL',
|
||||
})}
|
||||
{...inputs.kibanaUrls.formRowProps}
|
||||
>
|
||||
<EuiComboBox noSuggestions {...inputs.kibanaUrls.props} />
|
||||
</EuiFormRow>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.fleet.settings.elasticsearchUrlLabel', {
|
||||
defaultMessage: 'Elasticsearch URL',
|
||||
})}
|
||||
{...inputs.elasticsearchUrl.formRowProps}
|
||||
>
|
||||
<EuiComboBox noSuggestions {...inputs.elasticsearchUrl.props} />
|
||||
</EuiFormRow>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow fullWidth>
|
||||
<EuiFormRow
|
||||
{...inputs.additionalYamlConfig.formRowProps}
|
||||
label={i18n.translate('xpack.fleet.settings.additionalYamlConfig', {
|
||||
defaultMessage: 'Elasticsearch output configuration',
|
||||
})}
|
||||
fullWidth={true}
|
||||
>
|
||||
<EuiCodeEditor
|
||||
width="100%"
|
||||
mode="yaml"
|
||||
theme="textmate"
|
||||
setOptions={{
|
||||
minLines: 10,
|
||||
maxLines: 30,
|
||||
tabSize: 2,
|
||||
showGutter: false,
|
||||
}}
|
||||
{...inputs.additionalYamlConfig.props}
|
||||
onChange={inputs.additionalYamlConfig.setValue}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFormRow>
|
||||
</EuiForm>
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFlyout onClose={onClose} size="l" maxWidth={640}>
|
||||
<EuiFlyoutHeader hasBorder aria-labelledby="IngestManagerSettingsFlyoutTitle">
|
||||
<EuiTitle size="m">
|
||||
<h2 id="IngestManagerSettingsFlyoutTitle">
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settings.flyoutTitle"
|
||||
defaultMessage="Fleet settings"
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>{body}</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty onClick={onClose} flush="left">
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settings.cancelButtonLabel"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton onClick={onSubmit} iconType="save" isLoading={isLoading}>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settings.saveButtonLabel"
|
||||
defaultMessage="Save settings"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,188 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiModal,
|
||||
EuiModalHeader,
|
||||
EuiModalHeaderTitle,
|
||||
EuiModalFooter,
|
||||
EuiModalBody,
|
||||
EuiCallOut,
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiBasicTable,
|
||||
EuiText,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import type { EuiBasicTableProps } from '@elastic/eui';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
export interface SettingsConfirmModalProps {
|
||||
changes: Array<{
|
||||
type: 'elasticsearch' | 'fleet_server';
|
||||
direction: 'removed' | 'added';
|
||||
urls: string[];
|
||||
}>;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Change = SettingsConfirmModalProps['changes'][0];
|
||||
|
||||
const TABLE_COLUMNS: EuiBasicTableProps<Change>['columns'] = [
|
||||
{
|
||||
name: i18n.translate('xpack.fleet.settingsConfirmModal.fieldLabel', {
|
||||
defaultMessage: 'Field',
|
||||
}),
|
||||
field: 'label',
|
||||
render: (_, item) => getLabel(item),
|
||||
width: '180px',
|
||||
},
|
||||
{
|
||||
field: 'urls',
|
||||
name: i18n.translate('xpack.fleet.settingsConfirmModal.valueLabel', {
|
||||
defaultMessage: 'Value',
|
||||
}),
|
||||
render: (_, item) => {
|
||||
return (
|
||||
<EuiText size="s" color={item.direction === 'added' ? 'secondary' : 'danger'}>
|
||||
{item.urls.map((url) => (
|
||||
<div key={url}>{url}</div>
|
||||
))}
|
||||
</EuiText>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function getLabel(change: Change) {
|
||||
if (change.type === 'elasticsearch' && change.direction === 'removed') {
|
||||
return i18n.translate('xpack.fleet.settingsConfirmModal.elasticsearchRemovedLabel', {
|
||||
defaultMessage: 'Elasticsearch hosts (old)',
|
||||
});
|
||||
}
|
||||
|
||||
if (change.type === 'elasticsearch' && change.direction === 'added') {
|
||||
return i18n.translate('xpack.fleet.settingsConfirmModal.elasticsearchAddedLabel', {
|
||||
defaultMessage: 'Elasticsearch hosts (new)',
|
||||
});
|
||||
}
|
||||
|
||||
if (change.type === 'fleet_server' && change.direction === 'removed') {
|
||||
return i18n.translate('xpack.fleet.settingsConfirmModal.fleetServerRemovedLabel', {
|
||||
defaultMessage: 'Fleet Server hosts (old)',
|
||||
});
|
||||
}
|
||||
|
||||
if (change.type === 'fleet_server' && change.direction === 'added') {
|
||||
return i18n.translate('xpack.fleet.settingsConfirmModal.fleetServerAddedLabel', {
|
||||
defaultMessage: 'Fleet Server hosts (new)',
|
||||
});
|
||||
}
|
||||
|
||||
return i18n.translate('xpack.fleet.settingsConfirmModal.defaultChangeLabel', {
|
||||
defaultMessage: 'Unknown setting',
|
||||
});
|
||||
}
|
||||
|
||||
export const SettingsConfirmModal = React.memo<SettingsConfirmModalProps>(
|
||||
({ changes, onConfirm, onClose }) => {
|
||||
const hasESChanges = changes.some((change) => change.type === 'elasticsearch');
|
||||
const hasFleetServerChanges = changes.some((change) => change.type === 'fleet_server');
|
||||
|
||||
return (
|
||||
<EuiModal maxWidth={true} onClose={onClose}>
|
||||
<EuiModalHeader>
|
||||
<EuiModalHeaderTitle>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settingsConfirmModal.title"
|
||||
defaultMessage="Apply settings to all agent policies"
|
||||
/>
|
||||
</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
|
||||
<EuiModalBody>
|
||||
<EuiCallOut
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settingsConfirmModal.calloutTitle"
|
||||
defaultMessage="This action will update all agent policies and enrolled agents."
|
||||
/>
|
||||
}
|
||||
color="warning"
|
||||
iconType="alert"
|
||||
>
|
||||
<EuiText size="s">
|
||||
{hasFleetServerChanges && (
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settingsConfirmModal.fleetServerChangedText"
|
||||
defaultMessage="If agents are unable to connect at the new {fleetServerUrl}, they will log an error and report an unhealthy status. They will remain on the current agent policy version and check for updates at the old URL until they successfully connect at the new URL"
|
||||
values={{
|
||||
fleetServerUrl: (
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settingsConfirmModal.fleetServerUrl"
|
||||
defaultMessage="Fleet Server URL"
|
||||
/>
|
||||
</strong>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasESChanges && (
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settingsConfirmModal.eserverChangedText"
|
||||
defaultMessage="If agents are unable to connect at the new {elasticsearchUrl}, Fleet Server will report them as healthy but they will be unable to send data to Elasticsearch. This will not update URL that Fleet server itself uses to connect to Elasticsearch; you must manually reenroll it to update the URL"
|
||||
values={{
|
||||
elasticsearchUrl: (
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settingsConfirmModal.elasticsearchUrl"
|
||||
defaultMessage="Elasticsearch URL"
|
||||
/>
|
||||
</strong>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</EuiText>
|
||||
</EuiCallOut>
|
||||
|
||||
{changes.length > 0 && (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiBasicTable tableLayout="auto" columns={TABLE_COLUMNS} items={changes} />
|
||||
</>
|
||||
)}
|
||||
</EuiModalBody>
|
||||
|
||||
<EuiModalFooter>
|
||||
<EuiButtonEmpty onClick={onClose}>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settingsConfirmModal.cancelButton"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
<EuiButton onClick={onConfirm} fill>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settingsConfirmModal.confirmButton"
|
||||
defaultMessage="Confirm changes"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiModalFooter>
|
||||
</EuiModal>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -0,0 +1,439 @@
|
|||
/*
|
||||
* 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, { useEffect, useCallback } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutHeader,
|
||||
EuiTitle,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiButtonEmpty,
|
||||
EuiSpacer,
|
||||
EuiButton,
|
||||
EuiFlyoutFooter,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
EuiComboBox,
|
||||
EuiCode,
|
||||
EuiCodeEditor,
|
||||
EuiLink,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiText } from '@elastic/eui';
|
||||
import { safeLoad } from 'js-yaml';
|
||||
|
||||
import {
|
||||
useComboInput,
|
||||
useStartServices,
|
||||
useGetSettings,
|
||||
useInput,
|
||||
sendPutSettings,
|
||||
} from '../../hooks';
|
||||
import { useGetOutputs, sendPutOutput } from '../../hooks/use_request/outputs';
|
||||
import { isDiffPathProtocol } from '../../../../../common/';
|
||||
|
||||
import { SettingsConfirmModal } from './confirm_modal';
|
||||
import type { SettingsConfirmModalProps } from './confirm_modal';
|
||||
|
||||
const URL_REGEX = /^(https?):\/\/[^\s$.?#].[^\s]*$/gm;
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function isSameArrayValue(arrayA: string[] = [], arrayB: string[] = []) {
|
||||
return arrayA.length === arrayB.length && arrayA.every((val, index) => val === arrayB[index]);
|
||||
}
|
||||
|
||||
function useSettingsForm(outputId: string | undefined, onSuccess: () => void) {
|
||||
const [isLoading, setIsloading] = React.useState(false);
|
||||
const { notifications } = useStartServices();
|
||||
const kibanaUrlsInput = useComboInput([], (value) => {
|
||||
if (value.length === 0) {
|
||||
return [
|
||||
i18n.translate('xpack.fleet.settings.kibanaUrlEmptyError', {
|
||||
defaultMessage: 'At least one URL is required',
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (value.some((v) => !v.match(URL_REGEX))) {
|
||||
return [
|
||||
i18n.translate('xpack.fleet.settings.kibanaUrlError', {
|
||||
defaultMessage: 'Invalid URL',
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (isDiffPathProtocol(value)) {
|
||||
return [
|
||||
i18n.translate('xpack.fleet.settings.kibanaUrlDifferentPathOrProtocolError', {
|
||||
defaultMessage: 'Protocol and path must be the same for each URL',
|
||||
}),
|
||||
];
|
||||
}
|
||||
});
|
||||
const fleetServerHostsInput = useComboInput([], (value) => {
|
||||
// TODO enable as part of https://github.com/elastic/kibana/issues/94303
|
||||
// if (value.length === 0) {
|
||||
// return [
|
||||
// i18n.translate('xpack.fleet.settings.fleetServerHostsEmptyError', {
|
||||
// defaultMessage: 'At least one URL is required',
|
||||
// }),
|
||||
// ];
|
||||
// }
|
||||
if (value.some((v) => !v.match(URL_REGEX))) {
|
||||
return [
|
||||
i18n.translate('xpack.fleet.settings.fleetServerHostsError', {
|
||||
defaultMessage: 'Invalid URL',
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (value.length && isDiffPathProtocol(value)) {
|
||||
return [
|
||||
i18n.translate('xpack.fleet.settings.fleetServerHostsDifferentPathOrProtocolError', {
|
||||
defaultMessage: 'Protocol and path must be the same for each URL',
|
||||
}),
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
const elasticsearchUrlInput = useComboInput([], (value) => {
|
||||
if (value.some((v) => !v.match(URL_REGEX))) {
|
||||
return [
|
||||
i18n.translate('xpack.fleet.settings.elasticHostError', {
|
||||
defaultMessage: 'Invalid URL',
|
||||
}),
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
const additionalYamlConfigInput = useInput('', (value) => {
|
||||
try {
|
||||
safeLoad(value);
|
||||
return;
|
||||
} catch (error) {
|
||||
return [
|
||||
i18n.translate('xpack.fleet.settings.invalidYamlFormatErrorMessage', {
|
||||
defaultMessage: 'Invalid YAML: {reason}',
|
||||
values: { reason: error.message },
|
||||
}),
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
const validate = useCallback(() => {
|
||||
if (
|
||||
!kibanaUrlsInput.validate() ||
|
||||
!fleetServerHostsInput.validate() ||
|
||||
!elasticsearchUrlInput.validate() ||
|
||||
!additionalYamlConfigInput.validate()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [kibanaUrlsInput, fleetServerHostsInput, elasticsearchUrlInput, additionalYamlConfigInput]);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
validate,
|
||||
submit: async () => {
|
||||
try {
|
||||
setIsloading(true);
|
||||
if (!outputId) {
|
||||
throw new Error('Unable to load outputs');
|
||||
}
|
||||
const outputResponse = await sendPutOutput(outputId, {
|
||||
hosts: elasticsearchUrlInput.value,
|
||||
config_yaml: additionalYamlConfigInput.value,
|
||||
});
|
||||
if (outputResponse.error) {
|
||||
throw outputResponse.error;
|
||||
}
|
||||
const settingsResponse = await sendPutSettings({
|
||||
kibana_urls: kibanaUrlsInput.value,
|
||||
fleet_server_hosts: fleetServerHostsInput.value,
|
||||
});
|
||||
if (settingsResponse.error) {
|
||||
throw settingsResponse.error;
|
||||
}
|
||||
notifications.toasts.addSuccess(
|
||||
i18n.translate('xpack.fleet.settings.success.message', {
|
||||
defaultMessage: 'Settings saved',
|
||||
})
|
||||
);
|
||||
setIsloading(false);
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
setIsloading(false);
|
||||
notifications.toasts.addError(error, {
|
||||
title: 'Error',
|
||||
});
|
||||
}
|
||||
},
|
||||
inputs: {
|
||||
fleetServerHosts: fleetServerHostsInput,
|
||||
kibanaUrls: kibanaUrlsInput,
|
||||
elasticsearchUrl: elasticsearchUrlInput,
|
||||
additionalYamlConfig: additionalYamlConfigInput,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const SettingFlyout: React.FunctionComponent<Props> = ({ onClose }) => {
|
||||
const settingsRequest = useGetSettings();
|
||||
const settings = settingsRequest?.data?.item;
|
||||
const outputsRequest = useGetOutputs();
|
||||
const output = outputsRequest.data?.items?.[0];
|
||||
const { inputs, submit, validate, isLoading } = useSettingsForm(output?.id, onClose);
|
||||
|
||||
const [isConfirmModalVisible, setConfirmModalVisible] = React.useState(false);
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
if (validate()) {
|
||||
setConfirmModalVisible(true);
|
||||
}
|
||||
}, [validate, setConfirmModalVisible]);
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
setConfirmModalVisible(false);
|
||||
submit();
|
||||
}, [submit]);
|
||||
|
||||
const onConfirmModalClose = useCallback(() => {
|
||||
setConfirmModalVisible(false);
|
||||
}, [setConfirmModalVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (output) {
|
||||
inputs.elasticsearchUrl.setValue(output.hosts || []);
|
||||
inputs.additionalYamlConfig.setValue(output.config_yaml || '');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [output]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
inputs.kibanaUrls.setValue([...settings.kibana_urls]);
|
||||
inputs.fleetServerHosts.setValue([...settings.fleet_server_hosts]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings]);
|
||||
|
||||
const isUpdated = React.useMemo(() => {
|
||||
if (!settings || !output) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
!isSameArrayValue(settings.kibana_urls, inputs.kibanaUrls.value) ||
|
||||
!isSameArrayValue(settings.fleet_server_hosts, inputs.fleetServerHosts.value) ||
|
||||
!isSameArrayValue(output.hosts, inputs.elasticsearchUrl.value) ||
|
||||
(output.config_yaml || '') !== inputs.additionalYamlConfig.value
|
||||
);
|
||||
}, [settings, inputs, output]);
|
||||
|
||||
const changes = React.useMemo(() => {
|
||||
if (!settings || !output || !isConfirmModalVisible) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tmpChanges: SettingsConfirmModalProps['changes'] = [];
|
||||
if (!isSameArrayValue(output.hosts, inputs.elasticsearchUrl.value)) {
|
||||
tmpChanges.push(
|
||||
{
|
||||
type: 'elasticsearch',
|
||||
direction: 'removed',
|
||||
urls: output.hosts || [],
|
||||
},
|
||||
{
|
||||
type: 'elasticsearch',
|
||||
direction: 'added',
|
||||
urls: inputs.elasticsearchUrl.value,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!isSameArrayValue(settings.fleet_server_hosts, inputs.fleetServerHosts.value)) {
|
||||
tmpChanges.push(
|
||||
{
|
||||
type: 'fleet_server',
|
||||
direction: 'removed',
|
||||
urls: settings.fleet_server_hosts,
|
||||
},
|
||||
{
|
||||
type: 'fleet_server',
|
||||
direction: 'added',
|
||||
urls: inputs.fleetServerHosts.value,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return tmpChanges;
|
||||
}, [settings, inputs, output, isConfirmModalVisible]);
|
||||
|
||||
const body = settings && (
|
||||
<EuiForm>
|
||||
<EuiTitle size="s">
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settings.globalOutputTitle"
|
||||
defaultMessage="Global output"
|
||||
/>
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiText color="subdued" size="s">
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settings.globalOutputDescription"
|
||||
defaultMessage="These settings are applied globally to the {outputs} section of all agent policies and will affect all enrolled agents if changed."
|
||||
values={{
|
||||
outputs: <EuiCode>outputs</EuiCode>,
|
||||
}}
|
||||
/>
|
||||
</EuiText>
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={i18n.translate('xpack.fleet.settings.fleetServerHostsLabel', {
|
||||
defaultMessage: 'Fleet Server hosts',
|
||||
})}
|
||||
helpText={
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settings.fleetServerHostsHelpTect"
|
||||
defaultMessage="Specify the URLs that your agents will use to connect to a Fleet Server. If multiple URLs exist, Fleet will show the first provided URL for enrollment purposes. For more information, see the {link}."
|
||||
values={{
|
||||
link: (
|
||||
<EuiLink
|
||||
href="https://www.elastic.co/guide/en/fleet/current/index.html"
|
||||
target="_blank"
|
||||
external
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settings.userGuideLink"
|
||||
defaultMessage="Fleet User Guide"
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
{...inputs.fleetServerHosts.formRowProps}
|
||||
>
|
||||
<EuiComboBox fullWidth noSuggestions {...inputs.fleetServerHosts.props} />
|
||||
</EuiFormRow>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
{/* // TODO remove as part of https://github.com/elastic/kibana/issues/94303 */}
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={i18n.translate('xpack.fleet.settings.kibanaUrlLabel', {
|
||||
defaultMessage: 'Kibana hosts',
|
||||
})}
|
||||
{...inputs.kibanaUrls.formRowProps}
|
||||
>
|
||||
<EuiComboBox fullWidth noSuggestions {...inputs.kibanaUrls.props} />
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={i18n.translate('xpack.fleet.settings.elasticsearchUrlLabel', {
|
||||
defaultMessage: 'Elasticsearch hosts',
|
||||
})}
|
||||
helpText={i18n.translate('xpack.fleet.settings.elasticsearchUrlsHelpTect', {
|
||||
defaultMessage: 'Specify the Elasticsearch URLs where agents will send data.',
|
||||
})}
|
||||
{...inputs.elasticsearchUrl.formRowProps}
|
||||
>
|
||||
<EuiComboBox fullWidth noSuggestions {...inputs.elasticsearchUrl.props} />
|
||||
</EuiFormRow>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow
|
||||
{...inputs.additionalYamlConfig.formRowProps}
|
||||
label={i18n.translate('xpack.fleet.settings.additionalYamlConfig', {
|
||||
defaultMessage: 'Elasticsearch output configuration (YAML)',
|
||||
})}
|
||||
fullWidth
|
||||
>
|
||||
<EuiCodeEditor
|
||||
width="100%"
|
||||
mode="yaml"
|
||||
theme="textmate"
|
||||
placeholder="# YAML settings here will be added to the Elasticsearch output section of each policy"
|
||||
setOptions={{
|
||||
minLines: 10,
|
||||
maxLines: 30,
|
||||
tabSize: 2,
|
||||
showGutter: false,
|
||||
}}
|
||||
{...inputs.additionalYamlConfig.props}
|
||||
onChange={inputs.additionalYamlConfig.setValue}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiForm>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isConfirmModalVisible && (
|
||||
<SettingsConfirmModal
|
||||
changes={changes}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onConfirmModalClose}
|
||||
/>
|
||||
)}
|
||||
<EuiFlyout onClose={onClose} size="l" maxWidth={640}>
|
||||
<EuiFlyoutHeader hasBorder aria-labelledby="IngestManagerSettingsFlyoutTitle">
|
||||
<EuiTitle size="m">
|
||||
<h2 id="IngestManagerSettingsFlyoutTitle">
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settings.flyoutTitle"
|
||||
defaultMessage="Fleet settings"
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>{body}</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty onClick={onClose} flush="left">
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settings.cancelButtonLabel"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
disabled={!isUpdated}
|
||||
onClick={onSubmit}
|
||||
isLoading={isLoading}
|
||||
color="primary"
|
||||
fill
|
||||
>
|
||||
{isLoading ? (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settings.saveButtonLoadingLabel"
|
||||
defaultMessage="Applying settings..."
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.settings.saveButtonLabel"
|
||||
defaultMessage="Save and apply settings"
|
||||
/>
|
||||
)}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -125,7 +125,7 @@ export const DefaultLayout: React.FunctionComponent<Props> = ({
|
|||
<EuiButtonEmpty iconType="gear" onClick={() => setIsSettingsFlyoutOpen(true)}>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.appNavigation.settingsButton"
|
||||
defaultMessage="Settings"
|
||||
defaultMessage="Fleet settings"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -56,6 +56,7 @@ export const ManagedInstructions = React.memo<Props>(({ agentPolicies }) => {
|
|||
apiKey={apiKey.data.item}
|
||||
kibanaUrl={kibanaUrl}
|
||||
kibanaCASha256={kibanaCASha256}
|
||||
fleetServerHosts={settings.data?.item?.fleet_server_hosts || []}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
|
@ -65,6 +65,11 @@ export const config: PluginConfigDescriptor = {
|
|||
host: schema.maybe(schema.string()),
|
||||
ca_sha256: schema.maybe(schema.string()),
|
||||
}),
|
||||
fleet_server: schema.maybe(
|
||||
schema.object({
|
||||
hosts: schema.maybe(schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }))),
|
||||
})
|
||||
),
|
||||
agentPolicyRolloutRateLimitIntervalMs: schema.number({
|
||||
defaultValue: AGENT_POLICY_ROLLOUT_RATE_LIMIT_INTERVAL_MS,
|
||||
}),
|
||||
|
|
|
@ -58,9 +58,11 @@ const getSavedObjectTypes = (
|
|||
},
|
||||
mappings: {
|
||||
properties: {
|
||||
fleet_server_hosts: { type: 'keyword' },
|
||||
has_seen_add_data_notice: { type: 'boolean', index: false },
|
||||
// TODO remove as part of https://github.com/elastic/kibana/issues/94303
|
||||
kibana_urls: { type: 'keyword' },
|
||||
kibana_ca_sha256: { type: 'keyword' },
|
||||
has_seen_add_data_notice: { type: 'boolean', index: false },
|
||||
},
|
||||
},
|
||||
migrations: {
|
||||
|
|
|
@ -171,6 +171,7 @@ describe('agent policy', () => {
|
|||
inputs: [],
|
||||
revision: 1,
|
||||
fleet: {
|
||||
hosts: ['http://localhost:5603'],
|
||||
kibana: {
|
||||
hosts: ['localhost:5603'],
|
||||
protocol: 'http',
|
||||
|
@ -206,6 +207,7 @@ describe('agent policy', () => {
|
|||
inputs: [],
|
||||
revision: 1,
|
||||
fleet: {
|
||||
hosts: ['http://localhost:5603'],
|
||||
kibana: {
|
||||
hosts: ['localhost:5603'],
|
||||
protocol: 'http',
|
||||
|
@ -242,6 +244,7 @@ describe('agent policy', () => {
|
|||
inputs: [],
|
||||
revision: 1,
|
||||
fleet: {
|
||||
hosts: ['http://localhost:5603'],
|
||||
kibana: {
|
||||
hosts: ['localhost:5603'],
|
||||
protocol: 'http',
|
||||
|
|
|
@ -706,12 +706,20 @@ class AgentPolicyService {
|
|||
} catch (error) {
|
||||
throw new Error('Default settings is not setup');
|
||||
}
|
||||
if (!settings.kibana_urls || !settings.kibana_urls.length)
|
||||
throw new Error('kibana_urls is missing');
|
||||
if (settings.fleet_server_hosts && settings.fleet_server_hosts.length) {
|
||||
fullAgentPolicy.fleet = {
|
||||
hosts: settings.fleet_server_hosts,
|
||||
};
|
||||
} // TODO remove as part of https://github.com/elastic/kibana/issues/94303
|
||||
else {
|
||||
if (!settings.kibana_urls || !settings.kibana_urls.length)
|
||||
throw new Error('kibana_urls is missing');
|
||||
|
||||
fullAgentPolicy.fleet = {
|
||||
kibana: getFullAgentPolicyKibanaConfig(settings.kibana_urls),
|
||||
};
|
||||
fullAgentPolicy.fleet = {
|
||||
hosts: settings.kibana_urls,
|
||||
kibana: getFullAgentPolicyKibanaConfig(settings.kibana_urls),
|
||||
};
|
||||
}
|
||||
}
|
||||
return fullAgentPolicy;
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ export async function getSettings(soClient: SavedObjectsClientContract): Promise
|
|||
return {
|
||||
id: settingsSo.id,
|
||||
...settingsSo.attributes,
|
||||
fleet_server_hosts: settingsSo.attributes.fleet_server_hosts || [],
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -81,7 +82,10 @@ export function createDefaultSettings(): BaseSettings {
|
|||
pathname: basePath.serverBasePath,
|
||||
});
|
||||
|
||||
const fleetServerHosts = appContextService.getConfig()?.agents?.fleet_server?.hosts ?? [];
|
||||
|
||||
return {
|
||||
kibana_urls: [cloudUrl || flagsUrl || defaultUrl].flat(),
|
||||
fleet_server_hosts: fleetServerHosts,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -13,6 +13,15 @@ export const GetSettingsRequestSchema = {};
|
|||
|
||||
export const PutSettingsRequestSchema = {
|
||||
body: schema.object({
|
||||
fleet_server_hosts: schema.maybe(
|
||||
schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }), {
|
||||
validate: (value) => {
|
||||
if (value.length && isDiffPathProtocol(value)) {
|
||||
return 'Protocol and path must be the same for each URL';
|
||||
}
|
||||
},
|
||||
})
|
||||
),
|
||||
kibana_urls: schema.maybe(
|
||||
schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }), {
|
||||
validate: (value) => {
|
||||
|
|
|
@ -8716,7 +8716,6 @@
|
|||
"xpack.fleet.settings.elasticHostError": "無効なURL",
|
||||
"xpack.fleet.settings.elasticsearchUrlLabel": "Elasticsearch URL",
|
||||
"xpack.fleet.settings.flyoutTitle": "Fleet 設定",
|
||||
"xpack.fleet.settings.globalOutputDescription": "データを送信する場所を指定します。これらの設定はすべてのElasticエージェントポリシーに適用されます。",
|
||||
"xpack.fleet.settings.globalOutputTitle": "グローバル出力",
|
||||
"xpack.fleet.settings.invalidYamlFormatErrorMessage": "無効なYAML形式:{reason}",
|
||||
"xpack.fleet.settings.kibanaUrlDifferentPathOrProtocolError": "各URLのプロトコルとパスは同じでなければなりません",
|
||||
|
|
|
@ -8798,7 +8798,6 @@
|
|||
"xpack.fleet.settings.elasticHostError": "URL 无效",
|
||||
"xpack.fleet.settings.elasticsearchUrlLabel": "Elasticsearch URL",
|
||||
"xpack.fleet.settings.flyoutTitle": "Fleet 设置",
|
||||
"xpack.fleet.settings.globalOutputDescription": "指定将数据发送到何处。这些设置将应用于所有的 Elastic 代理策略。",
|
||||
"xpack.fleet.settings.globalOutputTitle": "全局输出",
|
||||
"xpack.fleet.settings.invalidYamlFormatErrorMessage": "YAML 无效:{reason}",
|
||||
"xpack.fleet.settings.kibanaUrlDifferentPathOrProtocolError": "对于每个 URL,协议和路径必须相同",
|
||||
|
|
Loading…
Reference in a new issue