[8.15] [Infra] Limit the number of metrics accepted by Snapshot API (#188181) (#188231)

# Backport

This will backport the following commits from `main` to `8.15`:
- [[Infra] Limit the number of metrics accepted by Snapshot API
(#188181)](https://github.com/elastic/kibana/pull/188181)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Carlos
Crespo","email":"crespocarlos@users.noreply.github.com"},"sourceCommit":{"committedDate":"2024-07-12T13:53:53Z","message":"[Infra]
Limit the number of metrics accepted by Snapshot API (#188181)\n\npart
of [3628](https://github.com/elastic/observability-dev/issues/3628)\r\n-
private\r\n\r\n\r\n## Summary\r\n\r\nAfter adding 20 items, users can no
longer add more metrics and will see\r\nthe \"Add metric\" button
disabled with a tooltip\r\n\r\n<img width=\"1713\"
alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/c784b08b-e118-4491-b53d-46bfde898216\">\r\n\r\n\r\n###
How to test\r\n\r\n- Start a local Kibana instance pointing to an oblt
cluster\r\n- Navigate to Infrastructure\r\n- Try to add more than 20
metrics in the Metrics
dropdown.","sha":"f2d1a8b6d24486cedb0dad97e71cd660845f353c","branchLabelMapping":{"^v8.16.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","backport:all-open","ci:project-deploy-observability","Team:obs-ux-infra_services","v8.16.0"],"title":"[Infra]
Limit the number of metrics accepted by Snapshot
API","number":188181,"url":"https://github.com/elastic/kibana/pull/188181","mergeCommit":{"message":"[Infra]
Limit the number of metrics accepted by Snapshot API (#188181)\n\npart
of [3628](https://github.com/elastic/observability-dev/issues/3628)\r\n-
private\r\n\r\n\r\n## Summary\r\n\r\nAfter adding 20 items, users can no
longer add more metrics and will see\r\nthe \"Add metric\" button
disabled with a tooltip\r\n\r\n<img width=\"1713\"
alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/c784b08b-e118-4491-b53d-46bfde898216\">\r\n\r\n\r\n###
How to test\r\n\r\n- Start a local Kibana instance pointing to an oblt
cluster\r\n- Navigate to Infrastructure\r\n- Try to add more than 20
metrics in the Metrics
dropdown.","sha":"f2d1a8b6d24486cedb0dad97e71cd660845f353c"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v8.16.0","branchLabelMappingKey":"^v8.16.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/188181","number":188181,"mergeCommit":{"message":"[Infra]
Limit the number of metrics accepted by Snapshot API (#188181)\n\npart
of [3628](https://github.com/elastic/observability-dev/issues/3628)\r\n-
private\r\n\r\n\r\n## Summary\r\n\r\nAfter adding 20 items, users can no
longer add more metrics and will see\r\nthe \"Add metric\" button
disabled with a tooltip\r\n\r\n<img width=\"1713\"
alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/c784b08b-e118-4491-b53d-46bfde898216\">\r\n\r\n\r\n###
How to test\r\n\r\n- Start a local Kibana instance pointing to an oblt
cluster\r\n- Navigate to Infrastructure\r\n- Try to add more than 20
metrics in the Metrics
dropdown.","sha":"f2d1a8b6d24486cedb0dad97e71cd660845f353c"}}]}]
BACKPORT-->

Co-authored-by: Carlos Crespo <crespocarlos@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2024-07-12 17:38:31 +02:00 committed by GitHub
parent 27685e9782
commit 3354dbaba4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 236 additions and 113 deletions

View file

@ -42,3 +42,5 @@ export const DEFAULT_METRICS_VIEW_ATTRIBUTES = {
name: 'Metrics View',
timeFieldName: TIMESTAMP_FIELD,
};
export const SNAPSHOT_API_MAX_METRICS = 20;

View file

@ -198,6 +198,7 @@ export const CustomMetricForm = withTheme(({ theme, onCancel, onChange, metric }
options={fieldOptions}
onChange={handleFieldChange}
isClearable={false}
data-test-subj="infraCustomMetricFieldSelect"
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -9,6 +9,7 @@ import { EuiPopover } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useState, useCallback } from 'react';
import { SnapshotMetricType } from '@kbn/metrics-data-access-plugin/common';
import { SNAPSHOT_API_MAX_METRICS } from '../../../../../../../common/constants';
import { getCustomMetricLabel } from '../../../../../../../common/formatters/get_custom_metric_label';
import {
SnapshotMetricInput,
@ -132,10 +133,13 @@ export const WaffleMetricControls = ({
return null;
}
const canAdd = options.length + customMetrics.length < SNAPSHOT_API_MAX_METRICS;
const button = (
<DropdownButton
onClick={handleToggle}
label={i18n.translate('xpack.infra.waffle.metriclabel', { defaultMessage: 'Metric' })}
data-test-subj="infraInventoryMetricDropdown"
>
{currentLabel}
</DropdownButton>
@ -190,6 +194,7 @@ export const WaffleMetricControls = ({
mode={mode}
onSave={handleSaveEdit}
customMetrics={customMetrics}
disableAdd={!canAdd}
/>
</EuiPopover>
</>

View file

@ -70,5 +70,11 @@ export const MetricsContextMenu = ({
},
];
return <EuiContextMenu initialPanelId={0} panels={panels} />;
return (
<EuiContextMenu
initialPanelId={0}
panels={panels}
data-test-subj="infraInventoryMetricsContextMenu"
/>
);
};

View file

@ -5,98 +5,128 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui';
import {
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiButton,
EuiToolTip,
useEuiTheme,
} from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiTheme, withTheme } from '@kbn/kibana-react-plugin/common';
import { SNAPSHOT_API_MAX_METRICS } from '../../../../../../../common/constants';
import { CustomMetricMode } from './types';
import { SnapshotCustomMetricInput } from '../../../../../../../common/http_api/snapshot_api';
interface Props {
theme: EuiTheme | undefined;
onEdit: () => void;
onAdd: () => void;
onSave: () => void;
onEditCancel: () => void;
mode: CustomMetricMode;
customMetrics: SnapshotCustomMetricInput[];
disableAdd?: boolean;
}
export const ModeSwitcher = withTheme(
({ onSave, onEditCancel, onEdit, onAdd, mode, customMetrics, theme }: Props) => {
if (['editMetric', 'addMetric'].includes(mode)) {
return null;
}
return (
<div
style={{
borderTop: `${theme?.eui.euiBorderWidthThin} solid ${theme?.eui.euiBorderColor}`,
padding: 12,
}}
>
<EuiFlexGroup justifyContent="spaceBetween">
{mode === 'edit' ? (
<>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="infraModeSwitcherCancelButton"
size="s"
flush="left"
onClick={onEditCancel}
aria-label={i18n.translate(
'xpack.infra.waffle.customMetrics.modeSwitcher.cancelAriaLabel',
{ defaultMessage: 'Cancel edit mode' }
)}
>
<FormattedMessage
id="xpack.infra.waffle.customMetrics.modeSwitcher.cancel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="infraModeSwitcherSaveButton"
onClick={onSave}
size="s"
fill
aria-label={i18n.translate(
'xpack.infra.waffle.customMetrics.modeSwitcher.saveButtonAriaLabel',
{ defaultMessage: 'Save changes to custom metrics' }
)}
>
<FormattedMessage
id="xpack.infra.waffle.customMetrics.modeSwitcher.saveButton"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
</>
) : (
<>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="infraModeSwitcherEditButton"
size="s"
flush="left"
onClick={onEdit}
disabled={customMetrics.length === 0}
aria-label={i18n.translate(
'xpack.infra.waffle.customMetrics.modeSwitcher.editAriaLabel',
{ defaultMessage: 'Edit custom metrics' }
)}
>
<FormattedMessage
id="xpack.infra.waffle.customMetrics.modeSwitcher.edit"
defaultMessage="Edit"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
export const ModeSwitcher = ({
onSave,
onEditCancel,
onEdit,
onAdd,
mode,
customMetrics,
disableAdd = false,
}: Props) => {
const { euiTheme } = useEuiTheme();
if (['editMetric', 'addMetric'].includes(mode)) {
return null;
}
return (
<div
style={{
borderTop: `${euiTheme.border.thin} solid ${euiTheme.border.color}`,
padding: 12,
}}
>
<EuiFlexGroup justifyContent="spaceBetween">
{mode === 'edit' ? (
<>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="infraModeSwitcherCancelButton"
size="s"
flush="left"
onClick={onEditCancel}
aria-label={i18n.translate(
'xpack.infra.waffle.customMetrics.modeSwitcher.cancelAriaLabel',
{ defaultMessage: 'Cancel edit mode' }
)}
>
<FormattedMessage
id="xpack.infra.waffle.customMetrics.modeSwitcher.cancel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="infraModeSwitcherSaveButton"
onClick={onSave}
size="s"
fill
aria-label={i18n.translate(
'xpack.infra.waffle.customMetrics.modeSwitcher.saveButtonAriaLabel',
{ defaultMessage: 'Save changes to custom metrics' }
)}
>
<FormattedMessage
id="xpack.infra.waffle.customMetrics.modeSwitcher.saveButton"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
</>
) : (
<>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="infraModeSwitcherEditButton"
size="s"
flush="left"
onClick={onEdit}
disabled={customMetrics.length === 0}
aria-label={i18n.translate(
'xpack.infra.waffle.customMetrics.modeSwitcher.editAriaLabel',
{ defaultMessage: 'Edit custom metrics' }
)}
>
<FormattedMessage
id="xpack.infra.waffle.customMetrics.modeSwitcher.edit"
defaultMessage="Edit"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
content={
disableAdd
? i18n.translate(
'xpack.infra.waffle.customMetrics.modeSwitcher.addDisabledTooltip',
{
defaultMessage: 'Maximum number of {maxMetrics} metrics reached.',
values: { maxMetrics: SNAPSHOT_API_MAX_METRICS },
}
)
: undefined
}
>
<EuiButtonEmpty
data-test-subj="infraModeSwitcherAddMetricButton"
onClick={onAdd}
disabled={disableAdd}
size="s"
flush="right"
aria-label={i18n.translate(
@ -109,11 +139,11 @@ export const ModeSwitcher = withTheme(
defaultMessage="Add metric"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</>
)}
</EuiFlexGroup>
</div>
);
}
);
</EuiToolTip>
</EuiFlexItem>
</>
)}
</EuiFlexGroup>
</div>
);
};

View file

@ -6,21 +6,18 @@
*/
import Boom from '@hapi/boom';
import { schema } from '@kbn/config-schema';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { createRouteValidationFunction } from '@kbn/io-ts-utils';
import { SNAPSHOT_API_MAX_METRICS } from '../../../common/constants';
import { InfraBackendLibs } from '../../lib/infra_types';
import { UsageCollector } from '../../usage/usage_collector';
import { SnapshotRequestRT, SnapshotNodeResponseRT } from '../../../common/http_api/snapshot_api';
import { throwErrors } from '../../../common/runtime_types';
import { createSearchClient } from '../../lib/create_search_client';
import { getNodes } from './lib/get_nodes';
import { LogQueryFields } from '../../lib/metrics/types';
const escapeHatch = schema.object({}, { unknowns: 'allow' });
export const initSnapshotRoute = (libs: InfraBackendLibs) => {
const validateBody = createRouteValidationFunction(SnapshotRequestRT);
const { framework } = libs;
framework.registerRoute(
@ -28,34 +25,40 @@ export const initSnapshotRoute = (libs: InfraBackendLibs) => {
method: 'post',
path: '/api/metrics/snapshot',
validate: {
body: escapeHatch,
body: validateBody,
},
},
async (requestContext, request, response) => {
const snapshotRequest = pipe(
SnapshotRequestRT.decode(request.body),
fold(throwErrors(Boom.badRequest), identity)
);
const soClient = (await requestContext.core).savedObjects.client;
const source = await libs.sources.getSourceConfiguration(soClient, snapshotRequest.sourceId);
const compositeSize = libs.configuration.inventory.compositeSize;
const [, { logsShared }] = await libs.getStartServices();
const logQueryFields: LogQueryFields | undefined = await logsShared.logViews
.getScopedClient(request)
.getResolvedLogView({
type: 'log-view-reference',
logViewId: snapshotRequest.sourceId,
})
.then(
({ indices }) => ({ indexPattern: indices }),
() => undefined
);
UsageCollector.countNode(snapshotRequest.nodeType);
const client = createSearchClient(requestContext, framework, request);
const snapshotRequest = request.body;
try {
if (snapshotRequest.metrics.length > SNAPSHOT_API_MAX_METRICS) {
throw Boom.badRequest(
`'metrics' size is greater than maximum of ${SNAPSHOT_API_MAX_METRICS} allowed.`
);
}
const soClient = (await requestContext.core).savedObjects.client;
const source = await libs.sources.getSourceConfiguration(
soClient,
snapshotRequest.sourceId
);
const compositeSize = libs.configuration.inventory.compositeSize;
const [, { logsShared }] = await libs.getStartServices();
const logQueryFields: LogQueryFields | undefined = await logsShared.logViews
.getScopedClient(request)
.getResolvedLogView({
type: 'log-view-reference',
logViewId: snapshotRequest.sourceId,
})
.then(
({ indices }) => ({ indexPattern: indices }),
() => undefined
);
UsageCollector.countNode(snapshotRequest.nodeType);
const client = createSearchClient(requestContext, framework, request);
const snapshotResponse = await getNodes(
client,
snapshotRequest,

View file

@ -622,5 +622,26 @@ export default function ({ getService }: FtrProviderContext) {
}
});
});
describe('request validation', () => {
it('should return 400 when requesting more than 20 metrics', async () => {
const { min, max } = DATES['8.0.0'].logs_and_metrics;
await fetchSnapshot(
{
sourceId: 'default',
timerange: {
to: max,
from: min,
interval: '1m',
},
metrics: Array(21).fill({ type: 'cpu' }),
nodeType: 'host',
groupBy: [{ field: 'service.type' }],
includeTimeseries: true,
},
400
);
});
});
});
}

View file

@ -555,6 +555,39 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
});
});
});
it('should not allow adding more than 20 custom metrics', async () => {
// open
await pageObjects.infraHome.clickCustomMetricDropdown();
const fields = [
'process.cpu.pct',
'process.memory.pct',
'system.core.total.pct',
'system.core.user.pct',
'system.core.nice.pct',
'system.core.idle.pct',
'system.core.iowait.pct',
'system.core.irq.pct',
'system.core.softirq.pct',
'system.core.steal.pct',
'system.cpu.nice.pct',
'system.cpu.idle.pct',
'system.cpu.iowait.pct',
'system.cpu.irq.pct',
];
for (const field of fields) {
await pageObjects.infraHome.addCustomMetric(field);
}
const metricsCount = await pageObjects.infraHome.getMetricsContextMenuItemsCount();
// there are 6 default metrics in the context menu for hosts
expect(metricsCount).to.eql(20);
await pageObjects.infraHome.ensureCustomMetricAddButtonIsDisabled();
// close
await pageObjects.infraHome.clickCustomMetricDropdown();
});
});
describe('alerts flyouts', () => {

View file

@ -482,5 +482,27 @@ export function InfraHomePageProvider({ getService, getPageObjects }: FtrProvide
async clickCloseFlyoutButton() {
return testSubjects.click('euiFlyoutCloseButton');
},
async clickCustomMetricDropdown() {
await testSubjects.click('infraInventoryMetricDropdown');
},
async addCustomMetric(field: string) {
await testSubjects.click('infraModeSwitcherAddMetricButton');
const groupByCustomField = await testSubjects.find('infraCustomMetricFieldSelect');
await comboBox.setElement(groupByCustomField, field);
await testSubjects.click('infraCustomMetricFormSaveButton');
},
async getMetricsContextMenuItemsCount() {
const contextMenu = await testSubjects.find('infraInventoryMetricsContextMenu');
const menuItems = await contextMenu.findAllByCssSelector('button.euiContextMenuItem');
return menuItems.length;
},
async ensureCustomMetricAddButtonIsDisabled() {
const button = await testSubjects.find('infraModeSwitcherAddMetricButton');
expect(await button.getAttribute('disabled')).to.be('true');
},
};
}