[APM] Add Experimental Mode for APM UI (#139553)

* experimental tab

* moving apm specific settings

* reverting and addin LazyField

* one setting to rule them all

* renaming tab

* new setting option and flyout

* flyout footer

* refactoring

* creating hook

* removing utils

* saving on kbn adv settings

* saved object

* refactoring

* extracting apm experimental features

* auto subs

* removing auto-subscribe feature

* fixing ci

* removing common settings

* new api to fetch labs items

* renaming

* renaming labs settings

* fixing ci

* handling exception

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* fixing ci

* addressing pr comments

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Cauê Marcondes 2022-09-15 16:28:45 -04:00 committed by GitHub
parent 78f6244109
commit ed6411e813
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 482 additions and 42 deletions

View file

@ -20,6 +20,10 @@ export { ComponentRegistry } from './component_registry';
const LazyField = React.lazy(() => import('./management_app/components/field'));
export { LazyField };
export { toEditableConfig } from './management_app/lib/to_editable_config';
export function plugin(initializerContext: PluginInitializerContext) {
return new AdvancedSettingsPlugin();
}
export type { FieldState } from './management_app/types';

View file

@ -518,6 +518,10 @@ export const stackManagementSchema: MakeSchemaFrom<UsageStats> = {
type: 'boolean',
_meta: { description: 'Non-default value of setting.' },
},
'observability:apmLabsButton': {
type: 'boolean',
_meta: { description: 'Non-default value of setting.' },
},
'observability:apmProgressiveLoading': {
type: 'keyword',
_meta: { description: 'Non-default value of setting.' },

View file

@ -139,6 +139,7 @@ export interface UsageStats {
'lens:useFieldExistenceSampling': boolean;
'metrics:allowCheckingForFailedShards': boolean;
'observability:apmOperationsTab': boolean;
'observability:apmLabsButton': boolean;
'observability:apmProgressiveLoading': string;
'observability:apmServiceGroupMaxNumberOfServices': number;
'observability:apmServiceInventoryOptimizedSorting': boolean;

View file

@ -8622,6 +8622,12 @@
"description": "Non-default value of setting."
}
},
"observability:apmLabsButton": {
"type": "boolean",
"_meta": {
"description": "Non-default value of setting."
}
},
"observability:apmProgressiveLoading": {
"type": "keyword",
"_meta": {
@ -10257,4 +10263,4 @@
}
}
}
}
}

View file

@ -19,7 +19,8 @@
"triggersActionsUi",
"share",
"unifiedSearch",
"dataViews"
"dataViews",
"advancedSettings"
],
"optionalPlugins": [
"actions",
@ -48,4 +49,4 @@
"observability",
"esUiShared"
]
}
}

View file

@ -0,0 +1,91 @@
/*
* 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 { EuiButton } from '@elastic/eui';
import { LazyField } from '@kbn/advanced-settings-plugin/public';
import { i18n } from '@kbn/i18n';
import {
apmLabsButton,
apmProgressiveLoading,
apmServiceGroupMaxNumberOfServices,
defaultApmServiceEnvironment,
enableComparisonByDefault,
enableInspectEsQueries,
} from '@kbn/observability-plugin/common';
import { isEmpty } from 'lodash';
import React from 'react';
import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context';
import { useApmEditableSettings } from '../../../../hooks/use_apm_editable_settings';
const apmSettingsKeys = [
enableComparisonByDefault,
defaultApmServiceEnvironment,
apmProgressiveLoading,
apmServiceGroupMaxNumberOfServices,
enableInspectEsQueries,
apmLabsButton,
];
export function GeneralSettings() {
const { docLinks, notifications } = useApmPluginContext().core;
const {
handleFieldChange,
settingsEditableConfig,
unsavedChanges,
saveAll,
isSaving,
} = useApmEditableSettings(apmSettingsKeys);
async function handleSave() {
try {
const reloadPage = Object.keys(unsavedChanges).some((key) => {
return settingsEditableConfig[key].requiresPageReload;
});
await saveAll();
if (reloadPage) {
window.location.reload();
}
} catch (e) {
const error = e as Error;
notifications.toasts.addDanger({
title: i18n.translate('xpack.apm.apmSettings.save.error', {
defaultMessage: 'An error occurred while saving the settings',
}),
text: error.message,
});
}
}
return (
<>
{apmSettingsKeys.map((settingKey) => {
const editableConfig = settingsEditableConfig[settingKey];
return (
<LazyField
key={settingKey}
setting={editableConfig}
handleChange={handleFieldChange}
enableSaving
docLinks={docLinks.links}
toasts={notifications.toasts}
unsavedChanges={unsavedChanges[settingKey]}
/>
);
})}
<EuiButton
fill
isLoading={isSaving}
disabled={isEmpty(unsavedChanges)}
onClick={handleSave}
>
{i18n.translate('xpack.apm.labs.reload', {
defaultMessage: 'Reload to apply changes',
})}
</EuiButton>
</>
);
}

View file

@ -20,6 +20,7 @@ import { CustomLinkOverview } from '../../app/settings/custom_link';
import { Schema } from '../../app/settings/schema';
import { AnomalyDetection } from '../../app/settings/anomaly_detection';
import { AgentKeys } from '../../app/settings/agent_keys';
import { GeneralSettings } from '../../app/settings/general_settings';
function page({
title,
@ -54,6 +55,14 @@ export const settings = {
</Breadcrumb>
),
children: {
'/settings/general-settings': page({
title: i18n.translate(
'xpack.apm.views.settings.generalSettings.title',
{ defaultMessage: 'General settings' }
),
element: <GeneralSettings />,
tab: 'general-settings',
}),
'/settings/agent-configuration': page({
tab: 'agent-configuration',
title: i18n.translate(
@ -133,7 +142,7 @@ export const settings = {
tab: 'agent-keys',
}),
'/settings': {
element: <Redirect to="/settings/agent-configuration" />,
element: <Redirect to="/settings/general-settings" />,
},
},
},

View file

@ -8,12 +8,11 @@
import { EuiPageHeaderProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { History } from 'history';
import { useHistory } from 'react-router-dom';
import { CoreStart } from '@kbn/core/public';
import { ApmMainTemplate } from './apm_main_template';
import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context';
import { getLegacyApmHref } from '../../shared/links/apm/apm_link';
import { useApmRouter } from '../../../hooks/use_apm_router';
import { ApmRouter } from '../apm_route_config';
type Tab = NonNullable<EuiPageHeaderProps['tabs']>[0] & {
key:
@ -22,7 +21,8 @@ type Tab = NonNullable<EuiPageHeaderProps['tabs']>[0] & {
| 'anomaly-detection'
| 'apm-indices'
| 'custom-links'
| 'schema';
| 'schema'
| 'general-settings';
hidden?: boolean;
};
@ -33,8 +33,8 @@ interface Props {
export function SettingsTemplate({ children, selectedTab }: Props) {
const { core } = useApmPluginContext();
const history = useHistory();
const tabs = getTabs({ history, core, selectedTab });
const router = useApmRouter();
const tabs = getTabs({ core, selectedTab, router });
return (
<ApmMainTemplate
@ -52,51 +52,44 @@ export function SettingsTemplate({ children, selectedTab }: Props) {
}
function getTabs({
history,
core,
selectedTab,
router,
}: {
history: History;
core: CoreStart;
selectedTab: Tab['key'];
router: ApmRouter;
}) {
const { basePath } = core.http;
const canAccessML = !!core.application.capabilities.ml?.canAccessML;
const { search } = history.location;
const tabs: Tab[] = [
{
key: 'general-settings',
label: i18n.translate('xpack.apm.settings.generalSettings', {
defaultMessage: 'General settings',
}),
href: router.link('/settings/general-settings'),
},
{
key: 'agent-configuration',
label: i18n.translate('xpack.apm.settings.agentConfig', {
defaultMessage: 'Agent Configuration',
}),
href: getLegacyApmHref({
basePath,
path: `/settings/agent-configuration`,
search,
}),
href: router.link('/settings/agent-configuration'),
},
{
key: 'agent-keys',
label: i18n.translate('xpack.apm.settings.agentKeys', {
defaultMessage: 'Agent Keys',
}),
href: getLegacyApmHref({
basePath,
path: `/settings/agent-keys`,
search,
}),
href: router.link('/settings/agent-keys'),
},
{
key: 'anomaly-detection',
label: i18n.translate('xpack.apm.settings.anomalyDetection', {
defaultMessage: 'Anomaly detection',
}),
href: getLegacyApmHref({
basePath,
path: `/settings/anomaly-detection`,
search,
}),
href: router.link('/settings/anomaly-detection'),
hidden: !canAccessML,
},
{
@ -104,29 +97,21 @@ function getTabs({
label: i18n.translate('xpack.apm.settings.customizeApp', {
defaultMessage: 'Custom Links',
}),
href: getLegacyApmHref({
basePath,
path: `/settings/custom-links`,
search,
}),
href: router.link('/settings/custom-links'),
},
{
key: 'apm-indices',
label: i18n.translate('xpack.apm.settings.indices', {
defaultMessage: 'Indices',
}),
href: getLegacyApmHref({
basePath,
path: `/settings/apm-indices`,
search,
}),
href: router.link('/settings/apm-indices'),
},
{
key: 'schema',
label: i18n.translate('xpack.apm.settings.schema', {
defaultMessage: 'Schema',
}),
href: getLegacyApmHref({ basePath, path: `/settings/schema`, search }),
href: router.link('/settings/schema'),
},
];

View file

@ -6,6 +6,7 @@
*/
import { EuiHeaderLink, EuiHeaderLinks } from '@elastic/eui';
import { apmLabsButton } from '@kbn/observability-plugin/common';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { getAlertingCapabilities } from '../../alerting/get_alerting_capabilities';
@ -15,6 +16,7 @@ import { AlertingPopoverAndFlyout } from './alerting_popover_flyout';
import { AnomalyDetectionSetupLink } from './anomaly_detection_setup_link';
import { useServiceName } from '../../../hooks/use_service_name';
import { InspectorHeaderLink } from './inspector_header_link';
import { Labs } from './labs';
export function ApmHeaderActionMenu() {
const { core, plugins } = useApmPluginContext();
@ -40,8 +42,14 @@ export function ApmHeaderActionMenu() {
return basePath.prepend(path);
}
const isLabsButtonEnabled = core.uiSettings.get<boolean>(
apmLabsButton,
false
);
return (
<EuiHeaderLinks gutterSize="xs">
{isLabsButtonEnabled && <Labs />}
<EuiHeaderLink
color="text"
href={apmHref('/storage-explorer')}

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButtonEmpty } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
import { LabsFlyout } from './labs_flyout';
export function Labs() {
const [isOpen, setIsOpen] = useState(false);
function toggleFlyoutVisibility() {
setIsOpen((state) => !state);
}
return (
<>
<EuiButtonEmpty color="text" onClick={toggleFlyoutVisibility}>
{i18n.translate('xpack.apm.labs', { defaultMessage: 'Labs' })}
</EuiButtonEmpty>
{isOpen && <LabsFlyout onClose={toggleFlyoutVisibility} />}
</>
);
}

View file

@ -0,0 +1,147 @@
/*
* 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 {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiHorizontalRule,
EuiIcon,
EuiLoadingContent,
EuiTitle,
} from '@elastic/eui';
import { LazyField } from '@kbn/advanced-settings-plugin/public';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context';
import { useApmEditableSettings } from '../../../../hooks/use_apm_editable_settings';
import { useFetcher, FETCH_STATUS } from '../../../../hooks/use_fetcher';
interface Props {
onClose: () => void;
}
export function LabsFlyout({ onClose }: Props) {
const { docLinks, notifications } = useApmPluginContext().core;
const { data, status } = useFetcher(
(callApmApi) => callApmApi('GET /internal/apm/settings/labs'),
[]
);
const labsItems = data?.labsItems || [];
const {
handleFieldChange,
settingsEditableConfig,
unsavedChanges,
saveAll,
isSaving,
cleanUnsavedChanges,
} = useApmEditableSettings(labsItems);
async function handleSave() {
try {
const reloadPage = Object.keys(unsavedChanges).some((key) => {
return settingsEditableConfig[key].requiresPageReload;
});
await saveAll();
if (reloadPage) {
window.location.reload();
} else {
onClose();
}
} catch (e) {
const error = e as Error;
notifications.toasts.addDanger({
title: i18n.translate('xpack.apm.apmSettings.save.error', {
defaultMessage: 'An error occurred while saving the settings',
}),
text: error.message,
});
}
}
function handelCancel() {
cleanUnsavedChanges();
onClose();
}
const isLoading =
status === FETCH_STATUS.NOT_INITIATED || status === FETCH_STATUS.LOADING;
return (
<EuiFlyout onClose={onClose}>
<EuiFlyoutHeader hasBorder>
<EuiFlexGroup gutterSize="m">
<EuiFlexItem grow={false}>
<EuiIcon type="beaker" size="xl" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiTitle>
<h2>
{i18n.translate('xpack.apm.labs', {
defaultMessage: 'Labs',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
{isLoading ? (
<EuiLoadingContent lines={3} />
) : (
<>
<EuiFlyoutBody>
{labsItems.map((settingKey, i) => {
const editableConfig = settingsEditableConfig[settingKey];
return (
<>
<LazyField
key={settingKey}
setting={editableConfig}
handleChange={handleFieldChange}
enableSaving
docLinks={docLinks.links}
toasts={notifications.toasts}
unsavedChanges={unsavedChanges[settingKey]}
/>
<EuiHorizontalRule />
</>
);
})}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={handelCancel}>
{i18n.translate('xpack.apm.labs.cancel', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton fill isLoading={isSaving} onClick={handleSave}>
{i18n.translate('xpack.apm.labs.reload', {
defaultMessage: 'Reload to apply changes',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</>
)}
</EuiFlyout>
);
}

View file

@ -0,0 +1,104 @@
/*
* 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 { useKibana } from '@kbn/kibana-react-plugin/public';
import { useMemo, useState } from 'react';
import { FieldState } from '@kbn/advanced-settings-plugin/public';
import { toEditableConfig } from '@kbn/advanced-settings-plugin/public';
import { IUiSettingsClient } from '@kbn/core/public';
import { isEmpty } from 'lodash';
function getEditableConfig({
settingsKeys,
uiSettings,
}: {
settingsKeys: string[];
uiSettings?: IUiSettingsClient;
}) {
if (!uiSettings) {
return {};
}
const uiSettingsDefinition = uiSettings.getAll();
const config: Record<string, ReturnType<typeof toEditableConfig>> = {};
settingsKeys.forEach((key) => {
const settingDef = uiSettingsDefinition?.[key];
if (settingDef) {
const editableConfig = toEditableConfig({
def: settingDef,
name: key,
value: settingDef.userValue,
isCustom: uiSettings.isCustom(key),
isOverridden: uiSettings.isOverridden(key),
});
config[key] = editableConfig;
}
});
return config;
}
export function useApmEditableSettings(settingsKeys: string[]) {
const { services } = useKibana();
const { uiSettings } = services;
const [isSaving, setIsSaving] = useState(false);
const [forceReloadSettings, setForceReloadSettings] = useState(0);
const [unsavedChanges, setUnsavedChanges] = useState<
Record<string, FieldState>
>({});
const settingsEditableConfig = useMemo(
() => {
return getEditableConfig({ settingsKeys, uiSettings });
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[uiSettings, settingsKeys, forceReloadSettings]
);
function handleFieldChange(key: string, fieldState: FieldState) {
setUnsavedChanges((state) => {
const newState = { ...state };
const { value, defVal } = settingsEditableConfig[key];
const currentValue = value === undefined ? defVal : value;
if (currentValue === fieldState.value) {
// Delete property from unsaved object if user changes it to the value that was already saved
delete newState[key];
} else {
newState[key] = fieldState;
}
return newState;
});
}
function cleanUnsavedChanges() {
setUnsavedChanges({});
}
async function saveAll() {
if (uiSettings && !isEmpty(unsavedChanges)) {
try {
setIsSaving(true);
const arr = Object.entries(unsavedChanges).map(([key, fieldState]) =>
uiSettings.set(key, fieldState.value)
);
await Promise.all(arr);
setForceReloadSettings((state) => ++state);
cleanUnsavedChanges();
} finally {
setIsSaving(false);
}
}
}
return {
settingsEditableConfig,
unsavedChanges,
handleFieldChange,
saveAll,
isSaving,
cleanUnsavedChanges,
};
}

View file

@ -41,6 +41,7 @@ import { timeRangeMetadataRoute } from '../time_range_metadata/route';
import { traceRouteRepository } from '../traces/route';
import { transactionRouteRepository } from '../transactions/route';
import { storageExplorerRouteRepository } from '../storage_explorer/route';
import { labsRouteRepository } from '../settings/labs/route';
function getTypedGlobalApmServerRouteRepository() {
const repository = {
@ -75,6 +76,7 @@ function getTypedGlobalApmServerRouteRepository() {
...infrastructureRouteRepository,
...debugTelemetryRoute,
...timeRangeMetadataRoute,
...labsRouteRepository,
};
return repository;

View file

@ -0,0 +1,22 @@
/*
* 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 { uiSettings } from '@kbn/observability-plugin/server';
import { createApmServerRoute } from '../../apm_routes/create_apm_server_route';
const getLabsRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/settings/labs',
options: { tags: ['access:apm'] },
handler: async (): Promise<{ labsItems: string[] }> => {
const labsItems = Object.entries(uiSettings)
.filter(([key, value]): boolean | undefined => value.showInLabs)
.map(([key]): string => key);
return { labsItems };
},
});
export const labsRouteRepository = getLabsRoute;

View file

@ -12,15 +12,18 @@ export { formatDurationFromTimeUnitChar } from './utils/formatters';
export { ProcessorEvent } from './processor_event';
export {
enableNewSyntheticsView,
enableInspectEsQueries,
maxSuggestions,
enableComparisonByDefault,
defaultApmServiceEnvironment,
apmServiceInventoryOptimizedSorting,
apmProgressiveLoading,
enableServiceGroups,
apmServiceInventoryOptimizedSorting,
apmServiceGroupMaxNumberOfServices,
apmTraceExplorerTab,
apmOperationsTab,
apmLabsButton,
} from './ui_settings_keys';
export {

View file

@ -18,3 +18,4 @@ export const apmServiceGroupMaxNumberOfServices =
'observability:apmServiceGroupMaxNumberOfServices';
export const apmTraceExplorerTab = 'observability:apmTraceExplorerTab';
export const apmOperationsTab = 'observability:apmOperationsTab';
export const apmLabsButton = 'observability:apmLabsButton';

View file

@ -51,3 +51,5 @@ export const plugin = (initContext: PluginInitializerContext) =>
export type { Mappings, ObservabilityPluginSetup, ScopedAnnotationsClient };
export { createOrUpdateIndex, unwrapEsResponse, WrappedElasticsearchClientError };
export { uiSettings } from './ui_settings';

View file

@ -21,6 +21,7 @@ import {
apmServiceGroupMaxNumberOfServices,
apmTraceExplorerTab,
apmOperationsTab,
apmLabsButton,
} from '../common/ui_settings_keys';
const technicalPreviewLabel = i18n.translate(
@ -30,10 +31,12 @@ const technicalPreviewLabel = i18n.translate(
}
);
type UiSettings = UiSettingsParams<boolean | number | string> & { showInLabs?: boolean };
/**
* uiSettings definitions for Observability.
*/
export const uiSettings: Record<string, UiSettingsParams<boolean | number | string>> = {
export const uiSettings: Record<string, UiSettings> = {
[enableNewSyntheticsView]: {
category: [observabilityFeatureId],
name: i18n.translate('xpack.observability.enableNewSyntheticsViewExperimentName', {
@ -60,6 +63,7 @@ export const uiSettings: Record<string, UiSettingsParams<boolean | number | stri
defaultMessage: 'Inspect Elasticsearch queries in API responses.',
}),
schema: schema.boolean(),
requiresPageReload: true,
},
[maxSuggestions]: {
category: [observabilityFeatureId],
@ -160,6 +164,7 @@ export const uiSettings: Record<string, UiSettingsParams<boolean | number | stri
}),
schema: schema.boolean(),
requiresPageReload: true,
showInLabs: true,
},
[apmServiceInventoryOptimizedSorting]: {
category: [observabilityFeatureId],
@ -178,6 +183,7 @@ export const uiSettings: Record<string, UiSettingsParams<boolean | number | stri
value: false,
requiresPageReload: false,
type: 'boolean',
showInLabs: true,
},
[apmServiceGroupMaxNumberOfServices]: {
category: [observabilityFeatureId],
@ -204,6 +210,7 @@ export const uiSettings: Record<string, UiSettingsParams<boolean | number | stri
value: false,
requiresPageReload: true,
type: 'boolean',
showInLabs: true,
},
[apmOperationsTab]: {
category: [observabilityFeatureId],
@ -219,5 +226,20 @@ export const uiSettings: Record<string, UiSettingsParams<boolean | number | stri
value: false,
requiresPageReload: true,
type: 'boolean',
showInLabs: true,
},
[apmLabsButton]: {
category: [observabilityFeatureId],
name: i18n.translate('xpack.observability.apmLabs', {
defaultMessage: 'Enable labs button in APM',
}),
description: i18n.translate('xpack.observability.apmLabsDescription', {
defaultMessage:
'This flag determines if the viewer has access to the Labs button, a quick way to enable and disable technical preview features in APM.',
}),
schema: schema.boolean(),
value: false,
requiresPageReload: true,
type: 'boolean',
},
};