diff --git a/x-pack/platform/plugins/private/translations/translations/de-DE.json b/x-pack/platform/plugins/private/translations/translations/de-DE.json index 42b96b08565de..03bb6c7a3f763 100644 --- a/x-pack/platform/plugins/private/translations/translations/de-DE.json +++ b/x-pack/platform/plugins/private/translations/translations/de-DE.json @@ -21373,15 +21373,15 @@ "xpack.infra.metricsExplorer.actionsLabel.aria": "Aktionen für {grouping}", "xpack.infra.metricsExplorer.actionsLabel.button": "Aktionen", "xpack.infra.metricsExplorer.aggregationLabel": "Von", - "xpack.infra.metricsExplorer.aggregationLables.avg": "Durchschnitt", - "xpack.infra.metricsExplorer.aggregationLables.cardinality": "Kardinalität", - "xpack.infra.metricsExplorer.aggregationLables.count": "Dokumentanzahl", - "xpack.infra.metricsExplorer.aggregationLables.max": "Max.", - "xpack.infra.metricsExplorer.aggregationLables.min": "Min", - "xpack.infra.metricsExplorer.aggregationLables.p95": "95. Perzentil", - "xpack.infra.metricsExplorer.aggregationLables.p99": "99. Perzentil", - "xpack.infra.metricsExplorer.aggregationLables.rate": "Rate", - "xpack.infra.metricsExplorer.aggregationLables.sum": "Summe", + "xpack.infra.metricsExplorer.aggregationLabels.avg": "Durchschnitt", + "xpack.infra.metricsExplorer.aggregationLabels.cardinality": "Kardinalität", + "xpack.infra.metricsExplorer.aggregationLabels.count": "Dokumentanzahl", + "xpack.infra.metricsExplorer.aggregationLabels.max": "Max.", + "xpack.infra.metricsExplorer.aggregationLabels.min": "Min", + "xpack.infra.metricsExplorer.aggregationLabels.p95": "95. Perzentil", + "xpack.infra.metricsExplorer.aggregationLabels.p99": "99. Perzentil", + "xpack.infra.metricsExplorer.aggregationLabels.rate": "Rate", + "xpack.infra.metricsExplorer.aggregationLabels.sum": "Summe", "xpack.infra.metricsExplorer.aggregationSelectLabel": "Aggregation auswählen", "xpack.infra.metricsExplorer.alerts.createRuleButton": "Schwellenwertregel erstellen", "xpack.infra.metricsExplorer.andLabel": "\" und \"", @@ -21626,10 +21626,10 @@ "xpack.infra.waffle.customMetricPanelLabel.addAriaLabel": "Zurück zur Metrikauswahl", "xpack.infra.waffle.customMetricPanelLabel.edit": "Benutzerdefinierte Metrik bearbeiten", "xpack.infra.waffle.customMetricPanelLabel.editAriaLabel": "Zurück in den Bearbeitungsmodus für benutzerdefinierte Metriken", - "xpack.infra.waffle.customMetrics.aggregationLables.avg": "Durchschnitt", - "xpack.infra.waffle.customMetrics.aggregationLables.max": "Max.", - "xpack.infra.waffle.customMetrics.aggregationLables.min": "Min.", - "xpack.infra.waffle.customMetrics.aggregationLables.rate": "Rate", + "xpack.infra.waffle.customMetrics.aggregationLabels.avg": "Durchschnitt", + "xpack.infra.waffle.customMetrics.aggregationLabels.max": "Max.", + "xpack.infra.waffle.customMetrics.aggregationLabels.min": "Min.", + "xpack.infra.waffle.customMetrics.aggregationLabels.rate": "Rate", "xpack.infra.waffle.customMetrics.cancelLabel": "Abbrechen", "xpack.infra.waffle.customMetrics.editMode.deleteAriaLabel": "Benutzerdefinierte Metrik für {name} löschen", "xpack.infra.waffle.customMetrics.editMode.editButtonAriaLabel": "Benutzerdefinierte Metrik für {name} bearbeiten", @@ -45669,4 +45669,4 @@ "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "Dies ist ein Pflichtfeld.", "xpack.watcher.watcherDescription": "Erkennen Sie Änderungen an Ihren Daten, indem Sie Alerts erstellen, verwalten und überwachen." } -} +} \ No newline at end of file diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 3b91ba6b64b45..5bb2f1a937645 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -21641,15 +21641,15 @@ "xpack.infra.metricsExplorer.actionsLabel.aria": "Actions pour {grouping}", "xpack.infra.metricsExplorer.actionsLabel.button": "Actions", "xpack.infra.metricsExplorer.aggregationLabel": "sur", - "xpack.infra.metricsExplorer.aggregationLables.avg": "Moyenne", - "xpack.infra.metricsExplorer.aggregationLables.cardinality": "Cardinalité", - "xpack.infra.metricsExplorer.aggregationLables.count": "Compte du document", - "xpack.infra.metricsExplorer.aggregationLables.max": "Max.", - "xpack.infra.metricsExplorer.aggregationLables.min": "Min.", - "xpack.infra.metricsExplorer.aggregationLables.p95": "95e centile", - "xpack.infra.metricsExplorer.aggregationLables.p99": "99e centile", - "xpack.infra.metricsExplorer.aggregationLables.rate": "Taux", - "xpack.infra.metricsExplorer.aggregationLables.sum": "Somme", + "xpack.infra.metricsExplorer.aggregationLabels.avg": "Moyenne", + "xpack.infra.metricsExplorer.aggregationLabels.cardinality": "Cardinalité", + "xpack.infra.metricsExplorer.aggregationLabels.count": "Compte du document", + "xpack.infra.metricsExplorer.aggregationLabels.max": "Max.", + "xpack.infra.metricsExplorer.aggregationLabels.min": "Min.", + "xpack.infra.metricsExplorer.aggregationLabels.p95": "95e centile", + "xpack.infra.metricsExplorer.aggregationLabels.p99": "99e centile", + "xpack.infra.metricsExplorer.aggregationLabels.rate": "Taux", + "xpack.infra.metricsExplorer.aggregationLabels.sum": "Somme", "xpack.infra.metricsExplorer.aggregationSelectLabel": "Choisir une agrégation", "xpack.infra.metricsExplorer.alerts.createRuleButton": "Créer une règle de seuil", "xpack.infra.metricsExplorer.andLabel": "\" et \"", @@ -21894,10 +21894,10 @@ "xpack.infra.waffle.customMetricPanelLabel.addAriaLabel": "Retour vers le sélecteur d'indicateur", "xpack.infra.waffle.customMetricPanelLabel.edit": "Modifier un indicateur personnalisé", "xpack.infra.waffle.customMetricPanelLabel.editAriaLabel": "Retour vers le mode d'édition des indicateurs personnalisés", - "xpack.infra.waffle.customMetrics.aggregationLables.avg": "Moyenne", - "xpack.infra.waffle.customMetrics.aggregationLables.max": "Max.", - "xpack.infra.waffle.customMetrics.aggregationLables.min": "Min.", - "xpack.infra.waffle.customMetrics.aggregationLables.rate": "Taux", + "xpack.infra.waffle.customMetrics.aggregationLabels.avg": "Moyenne", + "xpack.infra.waffle.customMetrics.aggregationLabels.max": "Max.", + "xpack.infra.waffle.customMetrics.aggregationLabels.min": "Min.", + "xpack.infra.waffle.customMetrics.aggregationLabels.rate": "Taux", "xpack.infra.waffle.customMetrics.cancelLabel": "Annuler", "xpack.infra.waffle.customMetrics.editMode.deleteAriaLabel": "Supprimer l'indicateur personnalisé pour {name}", "xpack.infra.waffle.customMetrics.editMode.editButtonAriaLabel": "Modifier l'indicateur personnalisé pour {name}", @@ -46181,4 +46181,4 @@ "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "Ce champ est requis.", "xpack.watcher.watcherDescription": "Détectez les modifications survenant dans vos données en créant, gérant et monitorant des alertes." } -} +} \ No newline at end of file diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index b75da2328869a..6af8c43196c56 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -21670,15 +21670,15 @@ "xpack.infra.metricsExplorer.actionsLabel.aria": "{grouping} のアクション", "xpack.infra.metricsExplorer.actionsLabel.button": "アクション", "xpack.infra.metricsExplorer.aggregationLabel": "/", - "xpack.infra.metricsExplorer.aggregationLables.avg": "平均", - "xpack.infra.metricsExplorer.aggregationLables.cardinality": "基数", - "xpack.infra.metricsExplorer.aggregationLables.count": "ドキュメントカウント", - "xpack.infra.metricsExplorer.aggregationLables.max": "最高", - "xpack.infra.metricsExplorer.aggregationLables.min": "最低", - "xpack.infra.metricsExplorer.aggregationLables.p95": "95パーセンタイル", - "xpack.infra.metricsExplorer.aggregationLables.p99": "99パーセンタイル", - "xpack.infra.metricsExplorer.aggregationLables.rate": "レート", - "xpack.infra.metricsExplorer.aggregationLables.sum": "合計", + "xpack.infra.metricsExplorer.aggregationLabels.avg": "平均", + "xpack.infra.metricsExplorer.aggregationLabels.cardinality": "基数", + "xpack.infra.metricsExplorer.aggregationLabels.count": "ドキュメントカウント", + "xpack.infra.metricsExplorer.aggregationLabels.max": "最高", + "xpack.infra.metricsExplorer.aggregationLabels.min": "最低", + "xpack.infra.metricsExplorer.aggregationLabels.p95": "95パーセンタイル", + "xpack.infra.metricsExplorer.aggregationLabels.p99": "99パーセンタイル", + "xpack.infra.metricsExplorer.aggregationLabels.rate": "レート", + "xpack.infra.metricsExplorer.aggregationLabels.sum": "合計", "xpack.infra.metricsExplorer.aggregationSelectLabel": "集約を選択してください", "xpack.infra.metricsExplorer.alerts.createRuleButton": "しきい値ルールを作成", "xpack.infra.metricsExplorer.andLabel": "\"および\"", @@ -21923,10 +21923,10 @@ "xpack.infra.waffle.customMetricPanelLabel.addAriaLabel": "メトリックピッカーに戻る", "xpack.infra.waffle.customMetricPanelLabel.edit": "カスタムメトリックを編集", "xpack.infra.waffle.customMetricPanelLabel.editAriaLabel": "カスタムメトリック編集モードに戻る", - "xpack.infra.waffle.customMetrics.aggregationLables.avg": "平均", - "xpack.infra.waffle.customMetrics.aggregationLables.max": "最高", - "xpack.infra.waffle.customMetrics.aggregationLables.min": "最低", - "xpack.infra.waffle.customMetrics.aggregationLables.rate": "レート", + "xpack.infra.waffle.customMetrics.aggregationLabels.avg": "平均", + "xpack.infra.waffle.customMetrics.aggregationLabels.max": "最高", + "xpack.infra.waffle.customMetrics.aggregationLabels.min": "最低", + "xpack.infra.waffle.customMetrics.aggregationLabels.rate": "レート", "xpack.infra.waffle.customMetrics.cancelLabel": "キャンセル", "xpack.infra.waffle.customMetrics.editMode.deleteAriaLabel": "{name} のカスタムメトリックを削除", "xpack.infra.waffle.customMetrics.editMode.editButtonAriaLabel": "{name} のカスタムメトリックを編集", @@ -46225,4 +46225,4 @@ "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "フィールドを選択してください。", "xpack.watcher.watcherDescription": "アラートの作成、管理、監視によりデータへの変更を検知します。" } -} +} \ No newline at end of file diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index 1bfc202e7afb1..444ac4d860372 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -21659,15 +21659,15 @@ "xpack.infra.metricsExplorer.actionsLabel.aria": "适用于 {grouping} 的操作", "xpack.infra.metricsExplorer.actionsLabel.button": "操作", "xpack.infra.metricsExplorer.aggregationLabel": "/", - "xpack.infra.metricsExplorer.aggregationLables.avg": "平均值", - "xpack.infra.metricsExplorer.aggregationLables.cardinality": "基数", - "xpack.infra.metricsExplorer.aggregationLables.count": "文档计数", - "xpack.infra.metricsExplorer.aggregationLables.max": "最大值", - "xpack.infra.metricsExplorer.aggregationLables.min": "最小值", - "xpack.infra.metricsExplorer.aggregationLables.p95": "第 95 百分位值", - "xpack.infra.metricsExplorer.aggregationLables.p99": "第 99 百分位值", - "xpack.infra.metricsExplorer.aggregationLables.rate": "比率", - "xpack.infra.metricsExplorer.aggregationLables.sum": "求和", + "xpack.infra.metricsExplorer.aggregationLabels.avg": "平均值", + "xpack.infra.metricsExplorer.aggregationLabels.cardinality": "基数", + "xpack.infra.metricsExplorer.aggregationLabels.count": "文档计数", + "xpack.infra.metricsExplorer.aggregationLabels.max": "最大值", + "xpack.infra.metricsExplorer.aggregationLabels.min": "最小值", + "xpack.infra.metricsExplorer.aggregationLabels.p95": "第 95 百分位值", + "xpack.infra.metricsExplorer.aggregationLabels.p99": "第 99 百分位值", + "xpack.infra.metricsExplorer.aggregationLabels.rate": "比率", + "xpack.infra.metricsExplorer.aggregationLabels.sum": "求和", "xpack.infra.metricsExplorer.aggregationSelectLabel": "选择聚合", "xpack.infra.metricsExplorer.alerts.createRuleButton": "创建阈值规则", "xpack.infra.metricsExplorer.andLabel": "\" 且 \"", @@ -21912,10 +21912,10 @@ "xpack.infra.waffle.customMetricPanelLabel.addAriaLabel": "返回到指标选取器", "xpack.infra.waffle.customMetricPanelLabel.edit": "编辑定制指标", "xpack.infra.waffle.customMetricPanelLabel.editAriaLabel": "返回到定制指标编辑模式", - "xpack.infra.waffle.customMetrics.aggregationLables.avg": "平均值", - "xpack.infra.waffle.customMetrics.aggregationLables.max": "最大值", - "xpack.infra.waffle.customMetrics.aggregationLables.min": "最小值", - "xpack.infra.waffle.customMetrics.aggregationLables.rate": "比率", + "xpack.infra.waffle.customMetrics.aggregationLabels.avg": "平均值", + "xpack.infra.waffle.customMetrics.aggregationLabels.max": "最大值", + "xpack.infra.waffle.customMetrics.aggregationLabels.min": "最小值", + "xpack.infra.waffle.customMetrics.aggregationLabels.rate": "比率", "xpack.infra.waffle.customMetrics.cancelLabel": "取消", "xpack.infra.waffle.customMetrics.editMode.deleteAriaLabel": "删除 {name} 的定制指标", "xpack.infra.waffle.customMetrics.editMode.editButtonAriaLabel": "编辑 {name} 的定制指标", @@ -46204,4 +46204,4 @@ "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "此字段必填。", "xpack.watcher.watcherDescription": "通过创建、管理和监测警报来检测数据中的更改。" } -} +} \ No newline at end of file diff --git a/x-pack/solutions/observability/plugins/infra/common/formatters/get_custom_metric_label.ts b/x-pack/solutions/observability/plugins/infra/common/formatters/get_custom_metric_label.ts index c644482a42345..3d89ce80c69c4 100644 --- a/x-pack/solutions/observability/plugins/infra/common/formatters/get_custom_metric_label.ts +++ b/x-pack/solutions/observability/plugins/infra/common/formatters/get_custom_metric_label.ts @@ -26,6 +26,10 @@ export const getCustomMetricLabel = (metric: SnapshotCustomMetricInput) => { defaultMessage: 'Rate of {field}', values: { field: metric.field }, }), + last_value: i18n.translate('xpack.infra.waffle.aggregationNames.last_value', { + defaultMessage: 'Last value of {field}', + values: { field: metric.field }, + }), }; return metric.label ? metric.label : METRIC_LABELS[metric.aggregation]; }; diff --git a/x-pack/solutions/observability/plugins/infra/common/http_api/metrics_explorer.ts b/x-pack/solutions/observability/plugins/infra/common/http_api/metrics_explorer.ts index d8c92b89f1337..c775c0b5a45b4 100644 --- a/x-pack/solutions/observability/plugins/infra/common/http_api/metrics_explorer.ts +++ b/x-pack/solutions/observability/plugins/infra/common/http_api/metrics_explorer.ts @@ -19,6 +19,7 @@ export const METRIC_EXPLORER_AGGREGATIONS = [ 'p95', 'p99', 'custom', + 'last_value', ] as const; export const OMITTED_AGGREGATIONS_FOR_CUSTOM_METRICS = ['custom', 'rate', 'p95', 'p99']; diff --git a/x-pack/solutions/observability/plugins/infra/common/http_api/snapshot_api.ts b/x-pack/solutions/observability/plugins/infra/common/http_api/snapshot_api.ts index e5b8ce3a3581e..bcd4948c8b58b 100644 --- a/x-pack/solutions/observability/plugins/infra/common/http_api/snapshot_api.ts +++ b/x-pack/solutions/observability/plugins/infra/common/http_api/snapshot_api.ts @@ -75,7 +75,7 @@ export const SnapshotNamedMetricInputRT = rt.type({ type: SnapshotMetricTypeRT, }); -export const SNAPSHOT_CUSTOM_AGGREGATIONS = ['avg', 'max', 'min', 'rate'] as const; +export const SNAPSHOT_CUSTOM_AGGREGATIONS = ['avg', 'max', 'min', 'rate', 'last_value'] as const; export type SnapshotCustomAggregation = (typeof SNAPSHOT_CUSTOM_AGGREGATIONS)[number]; @@ -109,9 +109,9 @@ export const SnapshotRequestRT = rt.intersection([ groupBy: rt.union([SnapshotGroupByRT, rt.null]), nodeType: ItemTypeRT, sourceId: rt.string, - includeTimeseries: rt.union([rt.boolean, createLiteralValueFromUndefinedRT(true)]), }), rt.partial({ + includeTimeseries: rt.union([rt.boolean, createLiteralValueFromUndefinedRT(false)]), accountId: rt.string, region: rt.string, kuery: rt.string, diff --git a/x-pack/solutions/observability/plugins/infra/common/inventory_views/types.ts b/x-pack/solutions/observability/plugins/infra/common/inventory_views/types.ts index 964f000ffee19..e66af9a67d820 100644 --- a/x-pack/solutions/observability/plugins/infra/common/inventory_views/types.ts +++ b/x-pack/solutions/observability/plugins/infra/common/inventory_views/types.ts @@ -24,12 +24,29 @@ export const inventoryColorPaletteRT = rt.keyof({ negative: null, }); -const inventoryLegendOptionsRT = rt.type({ - palette: inventoryColorPaletteRT, - steps: inRangeRt(2, 18), - reverseColors: rt.boolean, +const inventoryLegendTypeRT = rt.keyof({ + gradient: null, + steps: null, }); +const inventoryLegendStepRT = rt.type({ + color: rt.string, + value: rt.number, + label: rt.string, +}); + +const inventoryLegendOptionsRT = rt.intersection([ + rt.type({ + palette: inventoryColorPaletteRT, + steps: inRangeRt(2, 18), + reverseColors: rt.boolean, + }), + rt.partial({ + type: inventoryLegendTypeRT, + rules: rt.array(inventoryLegendStepRT), + }), +]); + export const inventorySortOptionRT = rt.type({ by: rt.keyof({ name: null, value: null }), direction: rt.keyof({ asc: null, desc: null }), diff --git a/x-pack/solutions/observability/plugins/infra/common/snapshot_metric_i18n.ts b/x-pack/solutions/observability/plugins/infra/common/snapshot_metric_i18n.ts index 6e530a797c61f..e077d52ecbed3 100644 --- a/x-pack/solutions/observability/plugins/infra/common/snapshot_metric_i18n.ts +++ b/x-pack/solutions/observability/plugins/infra/common/snapshot_metric_i18n.ts @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; import { mapValues } from 'lodash'; import type { InventoryItemType, SnapshotMetricType } from '@kbn/metrics-data-access-plugin/common'; +import type { SnapshotCustomAggregation } from './http_api/snapshot_api'; // Lowercase versions of all metrics, for when they need to be used in the middle of a sentence; // these may need to be translated differently depending on language, e.g. still capitalizing "CPU" @@ -282,3 +283,21 @@ export const toMetricOpt = ( }; } }; + +export const AGGREGATION_LABELS: Record = { + ['avg']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLabels.avg', { + defaultMessage: 'Average', + }), + ['max']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLabels.max', { + defaultMessage: 'Max', + }), + ['min']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLabels.min', { + defaultMessage: 'Min', + }), + ['rate']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLabels.rate', { + defaultMessage: 'Rate', + }), + ['last_value']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLabels.lastValue', { + defaultMessage: 'Last value', + }), +}; diff --git a/x-pack/solutions/observability/plugins/infra/public/alerting/inventory/components/expression_chart.tsx b/x-pack/solutions/observability/plugins/infra/public/alerting/inventory/components/expression_chart.tsx index 7ba13d3bd5da9..5e21dc1e2870d 100644 --- a/x-pack/solutions/observability/plugins/infra/public/alerting/inventory/components/expression_chart.tsx +++ b/x-pack/solutions/observability/plugins/infra/public/alerting/inventory/components/expression_chart.tsx @@ -84,6 +84,7 @@ export const ExpressionChart = ({ region, timerange, schema, + includeTimeseries: true, }); const metric = { diff --git a/x-pack/solutions/observability/plugins/infra/public/alerting/inventory/components/metrics_expression.tsx b/x-pack/solutions/observability/plugins/infra/public/alerting/inventory/components/metrics_expression.tsx index 01223e80a171f..bde49e09e23bc 100644 --- a/x-pack/solutions/observability/plugins/infra/public/alerting/inventory/components/metrics_expression.tsx +++ b/x-pack/solutions/observability/plugins/infra/public/alerting/inventory/components/metrics_expression.tsx @@ -39,7 +39,7 @@ import { SnapshotCustomMetricInputRT, SNAPSHOT_CUSTOM_AGGREGATIONS, } from '../../../../common/http_api/snapshot_api'; - +import { AGGREGATION_LABELS } from '../../../../common/snapshot_metric_i18n'; interface Props { metric?: { value: string; text: string }; metrics: Array<{ value: string; text: string }>; @@ -71,22 +71,8 @@ const V2ToLegacyMapping: Record = { cpuV2: 'cpu', }; -const AGGREGATION_LABELS = { - ['avg']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLables.avg', { - defaultMessage: 'Average', - }), - ['max']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLables.max', { - defaultMessage: 'Max', - }), - ['min']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLables.min', { - defaultMessage: 'Min', - }), - ['rate']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLables.rate', { - defaultMessage: 'Rate', - }), -}; const aggregationOptions = SNAPSHOT_CUSTOM_AGGREGATIONS.map((k) => ({ - text: AGGREGATION_LABELS[k as SnapshotCustomAggregation], + text: AGGREGATION_LABELS[k], value: k, })); diff --git a/x-pack/solutions/observability/plugins/infra/public/common/inventory/types.ts b/x-pack/solutions/observability/plugins/infra/public/common/inventory/types.ts index d4d4eca3902bb..5f2d0401ff3ad 100644 --- a/x-pack/solutions/observability/plugins/infra/public/common/inventory/types.ts +++ b/x-pack/solutions/observability/plugins/infra/public/common/inventory/types.ts @@ -77,14 +77,13 @@ export const PALETTES = { export const StepRuleRT = rt.intersection([ rt.type({ value: rt.number, - operator: OperatorRT, color: rt.string, }), - rt.partial({ label: rt.string }), + rt.partial({ label: rt.string, operator: OperatorRT }), ]); export const StepLegendRT = rt.type({ - type: rt.literal('step'), + type: rt.literal('steps'), rules: rt.array(StepRuleRT), }); export type InfraWaffleMapStepRule = rt.TypeOf; diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx index f1e135ec73152..fe279a634eab2 100644 --- a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx +++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx @@ -66,8 +66,10 @@ export const Layout = React.memo(({ interval, nodes, loading }: Props) => { const { currentTime, jumpToTime, isAutoReloading } = useWaffleTimeContext(); const { applyFilterQuery } = useWaffleFiltersContext(); const legendPalette = legend?.palette ?? DEFAULT_LEGEND.palette; + const legendRules = legend?.rules ?? DEFAULT_LEGEND.rules; const legendSteps = legend?.steps ?? DEFAULT_LEGEND.steps; const legendReverseColors = legend?.reverseColors ?? DEFAULT_LEGEND.reverseColors; + const legendType = legend?.type ?? 'gradient'; const AUTO_REFRESH_INTERVAL = 5 * 1000; const { hasEcsSchema, hasSemconvSchema, hasEcsK8sIntegration, hasSemconvK8sIntegration } = @@ -84,7 +86,7 @@ export const Layout = React.memo(({ interval, nodes, loading }: Props) => { const options = { formatter: InfraFormatterType.percent, formatTemplate: '{{value}}', - legend: createLegend(legendPalette, legendSteps, legendReverseColors), + legend: createLegend(legendPalette, legendSteps, legendReverseColors, legendRules, legendType), metric, sort, groupBy, diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx index 80988d476e5a6..2fc5418c0131c 100644 --- a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx +++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx @@ -160,6 +160,7 @@ describe('ConditionalToolTip', () => { groupBy: [], nodeType: 'host', sourceId: 'default', + includeTimeseries: true, currentTime, accountId: '', region: '', diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx index 95d94a2229955..4166eb81e4219 100644 --- a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx +++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx @@ -58,6 +58,7 @@ export const ConditionalToolTip = ({ node, nodeType, currentTime }: Props) => { accountId: '', region: '', schema: preferredSchema, + includeTimeseries: true, }); const dataNode = first(nodes); diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.stories.tsx b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.stories.tsx new file mode 100644 index 0000000000000..871bf719651bc --- /dev/null +++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.stories.tsx @@ -0,0 +1,40 @@ +/* + * 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 type { Meta, StoryObj } from '@storybook/react'; +import { LegendControls } from './legend_controls'; + +const meta = { + component: LegendControls, + title: 'Waffle Map/Legend controls', +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + args: { + options: { + palette: 'cool', + reverseColors: false, + steps: 10, + }, + dataBounds: { + min: 0, + max: 0.0637590382345481, + }, + bounds: { + min: 0, + max: 0.0637590382345481, + }, + autoBounds: true, + boundsOverride: { + max: 1, + min: 0, + }, + onChange: () => {}, + }, +}; diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx index 42fd118290690..60a4b20fc890f 100644 --- a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx +++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx @@ -9,6 +9,7 @@ import type { EuiSwitchEvent } from '@elastic/eui'; import { EuiButtonEmpty, EuiButton, + EuiButtonGroup, EuiButtonIcon, EuiFieldNumber, EuiForm, @@ -21,13 +22,14 @@ import { EuiRange, EuiFlexGroup, EuiFlexItem, + useEuiTheme, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import styled from '@emotion/styled'; import type { SyntheticEvent } from 'react'; -import React, { useState, useCallback, useEffect } from 'react'; -import { first, last } from 'lodash'; +import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import { first, last, isEqual } from 'lodash'; import type { EuiRangeProps, EuiSelectProps } from '@elastic/eui'; import type { WaffleLegendOptions } from '../../hooks/use_waffle_options'; import { @@ -38,9 +40,10 @@ import { import { getColorPalette } from '../../lib/get_color_palette'; import { convertBoundsToPercents } from '../../lib/convert_bounds_to_percents'; import { ColorLabel } from './color_label'; +import { LegendSteps, type LegendStep, hasLegendStepsDuplicates } from './legend_steps'; import { PalettePreview } from './palette_preview'; -interface Props { +export interface Props { onChange: (options: { auto: boolean; bounds: InfraWaffleMapBounds; @@ -64,6 +67,31 @@ const PALETTE_NAMES: InventoryColorPalette[] = [ const PALETTE_OPTIONS = PALETTE_NAMES.map((name) => ({ text: PALETTES[name], value: name })); +interface DraftState { + auto: boolean; + bounds: { min: number; max: number }; + legend: WaffleLegendOptions; + type: 'gradient' | 'steps'; +} + +const createDraftState = ( + autoBounds: boolean, + boundsOverride: InfraWaffleMapBounds, + options: WaffleLegendOptions, + defaultSteps?: LegendStep[] +): DraftState => { + const type = options.type || 'gradient'; + return { + auto: autoBounds, + bounds: convertBoundsToPercents(boundsOverride), + legend: { + ...options, + rules: type === 'steps' ? options.rules ?? defaultSteps : options.rules, + }, + type, + }; +}; + export const LegendControls = ({ autoBounds, boundsOverride, @@ -71,16 +99,32 @@ export const LegendControls = ({ dataBounds, options, }: Props) => { + const { euiTheme } = useEuiTheme(); const [isPopoverOpen, setPopoverState] = useState(false); - const [draftAuto, setDraftAuto] = useState(autoBounds); - const [draftLegend, setLegendOptions] = useState(options); - const [draftBounds, setDraftBounds] = useState(convertBoundsToPercents(boundsOverride)); // should come from bounds prop + const defaultLegendSteps = useMemo( + () => [ + { color: euiTheme.colors.severity.success, label: 'OK', value: 0 }, + { color: euiTheme.colors.severity.warning, label: 'WARNING', value: 1 }, + { color: euiTheme.colors.severity.danger, label: 'CRITICAL', value: 2 }, + { color: euiTheme.colors.severity.unknown, label: 'UNKNOWN', value: 3 }, + ], + [euiTheme.colors.severity] + ); + const [draft, setDraft] = useState(() => + createDraftState(autoBounds, boundsOverride, options, defaultLegendSteps) + ); useEffect(() => { - if (draftAuto) { - setDraftBounds(convertBoundsToPercents(dataBounds)); + if (draft.auto) { + setDraft((prev) => ({ ...prev, bounds: convertBoundsToPercents(dataBounds) })); } - }, [autoBounds, dataBounds, draftAuto, onChange, options]); + }, [dataBounds, draft.auto]); + + // Sync draft state from current values when opening popover + const handleOpenPopover = useCallback(() => { + setDraft(createDraftState(autoBounds, boundsOverride, options, defaultLegendSteps)); + setPopoverState(true); + }, [autoBounds, boundsOverride, options, defaultLegendSteps]); const buttonComponent = ( setPopoverState(true)} + onClick={handleOpenPopover} data-test-subj="openLegendControlsButton" /> ); @@ -99,94 +143,111 @@ export const LegendControls = ({ const handleAutoChange = useCallback( (e: EuiSwitchEvent) => { const auto = e.target.checked; - setDraftAuto(auto); - if (!auto) { - setDraftBounds(convertBoundsToPercents(boundsOverride)); - } + setDraft((prev) => ({ + ...prev, + auto, + bounds: auto ? prev.bounds : convertBoundsToPercents(boundsOverride), + })); }, [boundsOverride] ); - const handleReverseColors = useCallback( - (e: EuiSwitchEvent) => { - setLegendOptions((previous) => ({ ...previous, reverseColors: e.target.checked })); - }, - [setLegendOptions] - ); + const handleReverseColors = useCallback((e: EuiSwitchEvent) => { + setDraft((prev) => ({ + ...prev, + legend: { ...prev.legend, reverseColors: e.target.checked }, + })); + }, []); - const handleMaxBounds = useCallback( - (e: SyntheticEvent) => { - const value = parseFloat(e.currentTarget.value); + const handleMaxBounds = useCallback((e: SyntheticEvent) => { + const value = parseFloat(e.currentTarget.value); + setDraft((prev) => { // Auto correct the max to be one larger then the min OR 100 - const max = value <= draftBounds.min ? draftBounds.min + 1 : value > 100 ? 100 : value; - setDraftBounds({ ...draftBounds, max }); - }, - [draftBounds] - ); + const max = value <= prev.bounds.min ? prev.bounds.min + 1 : value > 100 ? 100 : value; + return { ...prev, bounds: { ...prev.bounds, max } }; + }); + }, []); - const handleMinBounds = useCallback( - (e: SyntheticEvent) => { - const value = parseFloat(e.currentTarget.value); + const handleMinBounds = useCallback((e: SyntheticEvent) => { + const value = parseFloat(e.currentTarget.value); + setDraft((prev) => { // Auto correct the min to be one smaller then the max OR ZERO - const min = value >= draftBounds.max ? draftBounds.max - 1 : value < 0 ? 0 : value; - setDraftBounds({ ...draftBounds, min }); - }, - [draftBounds] - ); + const min = value >= prev.bounds.max ? prev.bounds.max - 1 : value < 0 ? 0 : value; + return { ...prev, bounds: { ...prev.bounds, min } }; + }); + }, []); const handleApplyClick = useCallback(() => { onChange({ - auto: draftAuto, - bounds: { min: draftBounds.min / 100, max: draftBounds.max / 100 }, - legend: draftLegend, + auto: draft.auto, + bounds: { min: draft.bounds.min / 100, max: draft.bounds.max / 100 }, + legend: { ...draft.legend, type: draft.type }, }); setPopoverState(false); - }, [onChange, draftAuto, draftBounds, draftLegend]); + }, [onChange, draft]); const handleCancelClick = useCallback(() => { - setDraftBounds(convertBoundsToPercents(boundsOverride)); - setDraftAuto(autoBounds); - setLegendOptions(options); + setDraft(createDraftState(autoBounds, boundsOverride, options, defaultLegendSteps)); setPopoverState(false); - }, [autoBounds, boundsOverride, options]); + }, [autoBounds, boundsOverride, options, defaultLegendSteps]); - const handleStepsChange = useCallback>( - (e) => { - const steps = parseInt((e.target as HTMLInputElement).value, 10); - setLegendOptions((previous) => ({ ...previous, steps })); - }, - [setLegendOptions] - ); + const handleStepsChange = useCallback>((e) => { + const steps = parseInt((e.target as HTMLInputElement).value, 10); + setDraft((prev) => ({ ...prev, legend: { ...prev.legend, steps } })); + }, []); + + const handlePaletteChange = useCallback>((e) => { + const palette = e.target.value as WaffleLegendOptions['palette']; + setDraft((prev) => ({ ...prev, legend: { ...prev.legend, palette } })); + }, []); - const handlePaletteChange = useCallback>( - (e) => { - const palette = e.target.value as WaffleLegendOptions['palette']; - setLegendOptions((previous) => ({ ...previous, palette })); + const handleRulesChange = useCallback((rules: LegendStep[]) => { + setDraft((prev) => ({ ...prev, legend: { ...prev.legend, rules } })); + }, []); + + const handleTypeChange = useCallback( + (id: string) => { + const newType = id as 'gradient' | 'steps'; + setDraft((prev) => ({ + ...prev, + type: newType, + legend: { + ...prev.legend, + rules: + newType === 'steps' && (!prev.legend.rules || prev.legend.rules.length === 0) + ? defaultLegendSteps + : prev.legend.rules, + }, + })); }, - [setLegendOptions] + [defaultLegendSteps] ); - const commited = - draftAuto === autoBounds && - boundsOverride.min * 100 === draftBounds.min && - boundsOverride.max * 100 === draftBounds.max && - options.steps === draftLegend.steps && - options.reverseColors === draftLegend.reverseColors && - options.palette === draftLegend.palette; + const originalState = createDraftState(autoBounds, boundsOverride, options, defaultLegendSteps); + const commited = isEqual(draft, originalState); - const boundsValidRange = draftBounds.min < draftBounds.max; + const boundsValidRange = draft.bounds.min < draft.bounds.max; const paletteColors = getColorPalette( - draftLegend.palette, - draftLegend.steps, - draftLegend.reverseColors + draft.legend.palette, + draft.legend.steps, + draft.legend.reverseColors ); - const errors = !boundsValidRange - ? [ - i18n.translate('xpack.infra.legendControls.boundRangeError', { - defaultMessage: 'Minimum must be smaller than the maximum', - }), - ] - : []; + + const stepsValid = + draft.type !== 'steps' || + (draft.legend.rules?.every((step) => step.label?.trim()) && + !hasLegendStepsDuplicates(draft.legend.rules ?? [])); + + const isFormValid = draft.type === 'gradient' ? boundsValidRange : stepsValid; + + const errors = + !boundsValidRange && draft.type === 'gradient' + ? [ + i18n.translate('xpack.infra.legendControls.boundRangeError', { + defaultMessage: 'Minimum must be smaller than the maximum', + }), + ] + : []; return ( + + - + {draft.type === 'gradient' && ( <> - - - - - - - - - - - - - - - + <> + + + + + + - } - isInvalid={!boundsValidRange} - display="columnCompressed" - error={errors} - > -
- -
-
- + + + - } - isInvalid={!boundsValidRange} - error={errors} - > -
- + + + + + + + } isInvalid={!boundsValidRange} - value={isNaN(draftBounds.max) ? '' : draftBounds.max} - name="legendMax" - onChange={handleMaxBounds} - append="%" - compressed - /> -
-
+ display="columnCompressed" + error={errors} + > +
+ +
+ + + } + isInvalid={!boundsValidRange} + error={errors} + > +
+ +
+
+ + )} + {draft.type === 'steps' && ( + + )} @@ -363,7 +462,7 @@ export const LegendControls = ({ type="submit" size="s" fill - disabled={commited || !boundsValidRange} + disabled={commited || !isFormValid} onClick={handleApplyClick} data-test-subj="applyLegendControlsButton" > diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_steps.tsx b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_steps.tsx new file mode 100644 index 0000000000000..df3c328b3ac43 --- /dev/null +++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_steps.tsx @@ -0,0 +1,282 @@ +/* + * 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 { + EuiBasicTable, + type EuiBasicTableColumn, + EuiButton, + EuiButtonIcon, + EuiCallOut, + EuiColorPicker, + EuiColorPickerSwatch, + EuiFieldNumber, + EuiFieldText, + EuiSpacer, + useEuiTheme, +} from '@elastic/eui'; +import React, { useCallback, useMemo, useRef } from 'react'; +import { i18n } from '@kbn/i18n'; + +export interface LegendStep { + color: string; + label: string; + value: number; +} + +interface LegendStepsProps { + steps: LegendStep[]; + onChange: (steps: LegendStep[]) => void; +} + +interface ColorCellProps { + color: string; + onChange: (color: string) => void; +} + +function ColorCell({ color, onChange }: ColorCellProps) { + return ( + + } + /> + ); +} + +const MAX_STEPS = 18; +const MIN_STEPS = 2; +const MAX_CONTAINER_HEIGHT = 500; + +export function hasLegendStepsDuplicates(steps: LegendStep[]): boolean { + const values = steps.map((s) => s.value); + const labels = steps.map((s) => s.label.trim()).filter((l) => l !== ''); + + const hasDuplicateValues = new Set(values).size !== values.length; + const hasDuplicateLabels = new Set(labels).size !== labels.length; + + return hasDuplicateValues || hasDuplicateLabels; +} + +export function LegendSteps({ steps, onChange }: LegendStepsProps) { + const { euiTheme } = useEuiTheme(); + const scrollContainerRef = useRef(null); + + const hasDuplicates = hasLegendStepsDuplicates(steps); + const hasEmptyLabels = !steps.every((step) => step.label?.trim()); + + const errors: string[] = []; + if (hasDuplicates) { + errors.push( + i18n.translate('xpack.infra.legendSteps.duplicateStepsError', { + defaultMessage: 'Steps cannot have duplicate values or labels', + }) + ); + } + if (hasEmptyLabels) { + errors.push( + i18n.translate('xpack.infra.legendSteps.emptyLabelsError', { + defaultMessage: 'All steps must have a label', + }) + ); + } + + const isDuplicateValue = useCallback( + (item: LegendStep, value: number) => { + return steps.some((s) => s !== item && s.value === value); + }, + [steps] + ); + + const isDuplicateLabel = useCallback( + (item: LegendStep, label: string) => { + // Only check non-empty labels + if (!label.trim()) return false; + return steps.some((s) => s !== item && s.label.trim() === label.trim()); + }, + [steps] + ); + + const updateStep = useCallback( + (step: LegendStep, updates: Partial) => { + const index = steps.findIndex((s) => s === step); + if (index === -1) return; + const updatedSteps = [...steps]; + updatedSteps[index] = { ...updatedSteps[index], ...updates }; + onChange(updatedSteps); + }, + [steps, onChange] + ); + + const handleDeleteStep = useCallback( + (step: LegendStep) => { + const updatedSteps = steps.filter((s) => s !== step); + onChange(updatedSteps); + }, + [steps, onChange] + ); + + const handleAddStep = useCallback(() => { + const newStep: LegendStep = { + color: euiTheme.colors.textSubdued, + label: '', + value: 0, + }; + onChange([...steps, newStep]); + + // Scroll to bottom after adding a step + requestAnimationFrame(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight; + } + }); + }, [steps, onChange, euiTheme.colors.textSubdued]); + + const columns: Array> = useMemo( + () => [ + { + field: 'color', + name: i18n.translate('xpack.infra.legendSteps.colorColumnLabel', { + defaultMessage: 'Color', + }), + width: '60px', + render: (color: string, item: LegendStep) => ( + updateStep(item, { color: newColor })} /> + ), + }, + { + field: 'label', + name: i18n.translate('xpack.infra.legendSteps.labelColumnLabel', { + defaultMessage: 'Label', + }), + render: (label: string, item: LegendStep) => ( + updateStep(item, { label: e.target.value })} + aria-label={i18n.translate('xpack.infra.legendSteps.labelInputAriaLabel', { + defaultMessage: 'Step label', + })} + data-test-subj={`infraLegendStepsLabelInput-${item.value}`} + /> + ), + }, + { + field: 'value', + name: i18n.translate('xpack.infra.legendSteps.valueColumnLabel', { + defaultMessage: 'Value', + }), + width: '80px', + render: (value: number, item: LegendStep) => ( + { + const parsed = parseFloat(e.target.value); + updateStep(item, { value: isNaN(parsed) ? 0 : parsed }); + }} + aria-label={i18n.translate('xpack.infra.legendSteps.valueInputAriaLabel', { + defaultMessage: 'Step value', + })} + data-test-subj={`infraLegendStepsValueInput-${item.value}`} + /> + ), + }, + { + field: 'actions', + name: '', + width: '40px', + render: (_value: unknown, item: LegendStep) => ( + handleDeleteStep(item)} + disabled={steps.length <= MIN_STEPS} + data-test-subj="infraLegendStepsDeleteStepButton" + /> + ), + }, + ], + [updateStep, handleDeleteStep, steps.length, isDuplicateLabel, isDuplicateValue] + ); + + return ( + <> + {errors.length > 0 && ( + <> + +
    + {errors.map((error, index) => ( +
  • {error}
  • + ))} +
+
+ + + )} +
+ ({ + 'data-test-subj': `legendStepRow-${item.value}`, + })} + /> +
+ + = MAX_STEPS} + > + {i18n.translate('xpack.infra.legendSteps.addStepButtonLabel', { + defaultMessage: 'Add step', + })} + + + ); +} diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/metric_control/custom_metric_form.tsx b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/metric_control/custom_metric_form.tsx index 77e082d161d86..890a3deacbd09 100644 --- a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/metric_control/custom_metric_form.tsx +++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/metric_control/custom_metric_form.tsx @@ -33,26 +33,12 @@ import { SnapshotCustomAggregationRT, } from '../../../../../../../common/http_api/snapshot_api'; import { useMetricsDataViewContext } from '../../../../../../containers/metrics_source'; +import { AGGREGATION_LABELS } from '../../../../../../../common/snapshot_metric_i18n'; interface SelectedOption { label: string; } -const AGGREGATION_LABELS = { - ['avg']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLables.avg', { - defaultMessage: 'Average', - }), - ['max']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLables.max', { - defaultMessage: 'Max', - }), - ['min']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLables.min', { - defaultMessage: 'Min', - }), - ['rate']: i18n.translate('xpack.infra.waffle.customMetrics.aggregationLables.rate', { - defaultMessage: 'Rate', - }), -}; - interface Props { metric?: SnapshotCustomMetricInput; customMetrics: SnapshotCustomMetricInput[]; diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/steps_legend.tsx b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/steps_legend.tsx index 2e990647fef7c..ad73bcdbb44f7 100644 --- a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/steps_legend.tsx +++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/components/waffle/steps_legend.tsx @@ -30,8 +30,7 @@ interface Props { } const createStep = (formatter: InfraFormatter) => (rule: InfraWaffleMapStepRule, index: number) => { - const label = - rule.label != null ? rule.label : `${OPERATORS[rule.operator]} ${formatter(rule.value)}`; + const label = rule.label != null ? rule.label : `${OPERATORS.eq} ${formatter(rule.value)}`; const squareStyle = { backgroundColor: darken(0.4, rule.color) }; const squareInnerStyle = { backgroundColor: rule.color }; return ( @@ -50,13 +49,20 @@ export const StepLegend: React.FC = ({ legend, formatter }) => { const StepLegendContainer = styled.div` display: flex; + flex-direction: column; padding: 10px 40px 10px 10px; + max-height: 50vh; + overflow-y: auto; `; const StepContainer = styled.div` display: flex; - margin-right: 20px + margin-bottom: 8px; align-items: center; + + &:last-child { + margin-bottom: 0; + } `; const StepSquare = styled.div` @@ -65,6 +71,7 @@ const StepSquare = styled.div` height: 24px; flex: 0 0 auto; margin-right: 5px; + margin-left: 5px; border-radius: 3px; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2); `; diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/hooks/use_snaphot.ts b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/hooks/use_snaphot.ts index d33acece1be94..ac4911e529506 100644 --- a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/hooks/use_snaphot.ts +++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/hooks/use_snaphot.ts @@ -15,12 +15,9 @@ import type { } from '../../../../../common/http_api/snapshot_api'; import { SnapshotNodeResponseRT } from '../../../../../common/http_api/snapshot_api'; -export interface UseSnapshotRequest - extends Omit { +export interface UseSnapshotRequest extends Omit { currentTime: number; - includeTimeseries?: boolean; timerange?: InfraTimerangeInput; - schema?: DataSchemaFormat | null; } @@ -61,7 +58,7 @@ const buildPayload = (args: UseSnapshotRequest): SnapshotRequest => { dropPartialBuckets = true, kuery, groupBy = null, - includeTimeseries = true, + includeTimeseries, metrics, nodeType, overrideCompositeSize, diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts index 691ca269f4fbf..9808e74a19a92 100644 --- a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts +++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts @@ -36,7 +36,9 @@ import { useInventoryViewsContext } from './use_inventory_views'; export const DEFAULT_LEGEND: WaffleLegendOptions = { palette: 'cool', steps: 10, + rules: [], reverseColors: false, + type: 'gradient', }; export const DEFAULT_WAFFLE_OPTIONS_STATE: WaffleOptionsState = { diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/lib/color_from_value.ts b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/lib/color_from_value.ts index f821778171655..05e0e7927956c 100644 --- a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/lib/color_from_value.ts +++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/lib/color_from_value.ts @@ -92,7 +92,7 @@ export const calculateStepColor = ( defaultColor = 'rgba(217, 217, 217, 1)' ): string => { return rules.reduce((color: string, rule) => { - const operatorFn = OPERATOR_TO_FN[rule.operator]; + const operatorFn = OPERATOR_TO_FN.gte; if (operatorFn(value, rule.value)) { return rule.color; } diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/lib/create_legend.ts b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/lib/create_legend.ts index bf30ec9b94774..7dfd95f65ebce 100644 --- a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/lib/create_legend.ts +++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/lib/create_legend.ts @@ -7,21 +7,29 @@ import type { InventoryColorPalette, - InfraWaffleMapSteppedGradientLegend, + InfraWaffleMapLegend, + InfraWaffleMapStepRule, } from '../../../../common/inventory/types'; import { getColorPalette } from './get_color_palette'; export const createLegend = ( name: InventoryColorPalette, steps: number = 10, - reverse: boolean = false -): InfraWaffleMapSteppedGradientLegend => { + reverse: boolean = false, + rules: InfraWaffleMapStepRule[] = [], + type: 'gradient' | 'steps' = 'gradient' +): InfraWaffleMapLegend => { const paletteColors = getColorPalette(name, steps, reverse); - return { - type: 'steppedGradient', - rules: paletteColors.map((color, index) => ({ - color, - value: (index + 1) / steps, - })), - }; + return type === 'steps' && rules.length > 0 + ? { + type: 'steps', + rules, + } + : { + type: 'steppedGradient', + rules: paletteColors.map((color, index) => ({ + color, + value: (index + 1) / steps, + })), + }; }; diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/metrics_explorer/components/aggregation.tsx b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/metrics_explorer/components/aggregation.tsx index feb6293b86595..69f2fedb65669 100644 --- a/x-pack/solutions/observability/plugins/infra/public/pages/metrics/metrics_explorer/components/aggregation.tsx +++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/metrics_explorer/components/aggregation.tsx @@ -24,35 +24,38 @@ interface Props { onChange: (aggregation: MetricsExplorerAggregation) => void; } -type MetricsExplorerAggregationWithoutCustom = Exclude; +type MetricsExplorerSelectableAggregation = Exclude< + MetricsExplorerAggregation, + 'custom' | 'last_value' +>; export const MetricsExplorerAggregationPicker = ({ options, onChange }: Props) => { const AGGREGATION_LABELS = { - ['avg']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.avg', { + ['avg']: i18n.translate('xpack.infra.metricsExplorer.aggregationLabels.avg', { defaultMessage: 'Average', }), - ['sum']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.sum', { + ['sum']: i18n.translate('xpack.infra.metricsExplorer.aggregationLabels.sum', { defaultMessage: 'Sum', }), - ['max']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.max', { + ['max']: i18n.translate('xpack.infra.metricsExplorer.aggregationLabels.max', { defaultMessage: 'Max', }), - ['min']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.min', { + ['min']: i18n.translate('xpack.infra.metricsExplorer.aggregationLabels.min', { defaultMessage: 'Min', }), - ['cardinality']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.cardinality', { + ['cardinality']: i18n.translate('xpack.infra.metricsExplorer.aggregationLabels.cardinality', { defaultMessage: 'Cardinality', }), - ['rate']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.rate', { + ['rate']: i18n.translate('xpack.infra.metricsExplorer.aggregationLabels.rate', { defaultMessage: 'Rate', }), - ['p95']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.p95', { + ['p95']: i18n.translate('xpack.infra.metricsExplorer.aggregationLabels.p95', { defaultMessage: '95th Percentile', }), - ['p99']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.p99', { + ['p99']: i18n.translate('xpack.infra.metricsExplorer.aggregationLabels.p99', { defaultMessage: '99th Percentile', }), - ['count']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.count', { + ['count']: i18n.translate('xpack.infra.metricsExplorer.aggregationLabels.count', { defaultMessage: 'Document count', }), }; @@ -72,7 +75,8 @@ export const MetricsExplorerAggregationPicker = ({ options, onChange }: Props) = const METRIC_EXPLORER_AGGREGATIONS_WITHOUT_CUSTOM = xor(METRIC_EXPLORER_AGGREGATIONS, [ 'custom', - ]) as MetricsExplorerAggregationWithoutCustom[]; + 'last_value', + ]) as MetricsExplorerSelectableAggregation[]; return ( + Object.values(metric.aggregations).some(isDerivativeAgg) + ); + const metricsApiRequest: MetricsAPIRequest = { indexPattern: sourceOverrides?.indexPattern ?? source.configuration.metricAlias, timerange: { @@ -70,7 +77,7 @@ export const transformRequestToMetricsAPIRequest = async ({ : compositeSize, alignDataToEnd: true, dropPartialBuckets: snapshotRequest.dropPartialBuckets ?? true, - includeTimeseries: snapshotRequest.includeTimeseries, + includeTimeseries, filters, }; diff --git a/x-pack/solutions/observability/plugins/infra/server/routes/snapshot/lib/transform_snapshot_metrics_to_metrics_api_metrics.ts b/x-pack/solutions/observability/plugins/infra/server/routes/snapshot/lib/transform_snapshot_metrics_to_metrics_api_metrics.ts index 9a438f7f76be7..b70ead5ca63a2 100644 --- a/x-pack/solutions/observability/plugins/infra/server/routes/snapshot/lib/transform_snapshot_metrics_to_metrics_api_metrics.ts +++ b/x-pack/solutions/observability/plugins/infra/server/routes/snapshot/lib/transform_snapshot_metrics_to_metrics_api_metrics.ts @@ -27,19 +27,41 @@ export const transformSnapshotMetricsToMetricsAPIMetrics = async ( SnapshotCustomMetricInputRT.is(m) ? m.id === metric.id : false ); const customId = isUniqueId ? metric.id : `custom_${index}`; + if (metric.aggregation === 'rate') { return { id: customId, aggregations: networkTraffic(customId, metric.field) }; - } - return { - id: customId, - aggregations: { - [customId]: { - [metric.aggregation]: { - field: metric.field, + } else if (metric.aggregation === 'last_value') { + return { + id: customId, + aggregations: { + [customId]: { + filter: { + exists: { field: metric.field }, + }, + aggs: { + value: { + top_metrics: { + metrics: { field: metric.field }, + size: 1, + sort: { '@timestamp': 'desc' }, + }, + }, + }, }, }, - }, - }; + }; + } else { + return { + id: customId, + aggregations: { + [customId]: { + [metric.aggregation]: { + field: metric.field, + }, + }, + }, + }; + } } return { id: metric.type, aggregations: aggregation }; }) diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/server/lib/metrics/lib/convert_buckets_to_metrics_series.ts b/x-pack/solutions/observability/plugins/metrics_data_access/server/lib/metrics/lib/convert_buckets_to_metrics_series.ts index 97b88bf861066..0737467b56bb9 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/server/lib/metrics/lib/convert_buckets_to_metrics_series.ts +++ b/x-pack/solutions/observability/plugins/metrics_data_access/server/lib/metrics/lib/convert_buckets_to_metrics_series.ts @@ -21,14 +21,20 @@ import { PercentilesKeyedTypeRT, TopMetricsTypeRT, MetricValueTypeRT, + FilterWithNestedAggRT, } from '../types'; const BASE_COLUMNS = [{ name: 'timestamp', type: 'date' }] as MetricsAPIColumn[]; -const ValueObjectTypeRT = rt.union([rt.string, rt.number, MetricValueTypeRT]); +const ValueObjectTypeRT = rt.union([ + rt.string, + rt.number, + MetricValueTypeRT, + FilterWithNestedAggRT, +]); type ValueObjectType = rt.TypeOf; -const getValue = (valueObject: ValueObjectType) => { +const getValue = (valueObject: ValueObjectType): number | null | object[] => { if (NormalizedMetricValueRT.is(valueObject)) { return valueObject.normalized_value || valueObject.value; } @@ -61,6 +67,17 @@ const getValue = (valueObject: ValueObjectType) => { return valueObject.top.map((res) => res.metrics); } + // Handle filter aggregation wrapping another aggregation (e.g., filter + top_metrics) + if (FilterWithNestedAggRT.is(valueObject)) { + const nestedKey = Object.keys(valueObject).find((k) => k !== 'doc_count' && k !== 'meta'); + if (nestedKey) { + const nestedValue = (valueObject as Record)[nestedKey]; + if (MetricValueTypeRT.is(nestedValue) || FilterWithNestedAggRT.is(nestedValue)) { + return getValue(nestedValue as ValueObjectType); + } + } + } + return null; }; @@ -68,6 +85,17 @@ const dropOutOfBoundsBuckets = (from: number, to: number, bucketSizeInMillis: number) => (row: MetricsAPIRow) => row.timestamp >= from && row.timestamp + bucketSizeInMillis <= to; +// Extract first numeric value from top_metrics result for non-metadata fields +const extractFirstNumericValue = (metricsArray: object[]): number | null => { + const firstItem = first(metricsArray); + if (!firstItem) return null; + const firstValue = first(values(firstItem)); + return typeof firstValue === 'number' ? firstValue : null; +}; + +// Metadata key that should keep full top_metrics array +const META_KEY = '__metadata__'; + export const convertBucketsToRows = ( options: MetricsAPIRequest, buckets: Bucket[] @@ -76,7 +104,9 @@ export const convertBucketsToRows = ( const ids = options.metrics.map((metric) => metric.id); const metrics = ids.reduce((acc, id) => { const valueObject = get(bucket, [id]); - acc[id] = ValueObjectTypeRT.is(valueObject) ? getValue(valueObject) : null; + const value = ValueObjectTypeRT.is(valueObject) ? getValue(valueObject) : null; + // For non-metadata fields, extract numeric value from top_metrics array + acc[id] = Array.isArray(value) && id !== META_KEY ? extractFirstNumericValue(value) : value; return acc; }, {} as Record); diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/server/lib/metrics/lib/create_aggregations.ts b/x-pack/solutions/observability/plugins/metrics_data_access/server/lib/metrics/lib/create_aggregations.ts index ab6a925038c98..b5db23aa9faa0 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/server/lib/metrics/lib/create_aggregations.ts +++ b/x-pack/solutions/observability/plugins/metrics_data_access/server/lib/metrics/lib/create_aggregations.ts @@ -8,6 +8,7 @@ import type { AggregationOptionsByType } from '@kbn/es-types'; import Boom from '@hapi/boom'; +import { isDerivativeAgg } from '../../../../common/inventory_models'; import { afterKeyObjectRT } from '../../../../common/http_api'; import { TIMESTAMP } from '../../../../common/constants'; import type { MetricsAPIRequest } from '../../../../common/http_api/metrics_api'; @@ -65,8 +66,14 @@ export const createCompositeAggregations = (options: MetricsAPIRequest) => { throw Boom.badRequest('groupBy must be informed.'); } - if (!options.includeTimeseries && !!options.metrics.find((p) => p.id === 'logRate')) { - throw Boom.badRequest('logRate metric is not supported without time series'); + const derivativeMetrics = Object.values(options.metrics) + .filter((metric) => Object.values(metric.aggregations).some(isDerivativeAgg)) + .map((metric) => metric.id); + + if (!options.includeTimeseries && derivativeMetrics.length > 0) { + throw Boom.badRequest( + `The following metrics require time series: ${derivativeMetrics.join(', ')}` + ); } const after = getAfterKey(options); diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/server/lib/metrics/types.ts b/x-pack/solutions/observability/plugins/metrics_data_access/server/lib/metrics/types.ts index df19f5aed0729..3e073326daedd 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/server/lib/metrics/types.ts +++ b/x-pack/solutions/observability/plugins/metrics_data_access/server/lib/metrics/types.ts @@ -40,6 +40,12 @@ export const MaxPeriodFilterExistsTypeRT = rt.type({ period: BasicMetricValueRT, }); +// Filter aggregation that wraps another aggregation (e.g., filter + top_metrics) +export const FilterWithNestedAggRT = rt.intersection([ + rt.type({ doc_count: rt.number }), + rt.record(rt.string, rt.unknown), +]); + export const MetricValueTypeRT = rt.union([ BasicMetricValueRT, NormalizedMetricValueRT, diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/infra/snapshot.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/infra/snapshot.ts index 806d67fa966c1..9b48bdf3955c9 100644 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/infra/snapshot.ts +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/infra/snapshot.ts @@ -287,30 +287,6 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { expect(firstNode.metrics).to.eql([expected]); } }); - - it('should fail to fetch logRate with no timeseries data', async () => { - const resp = await fetchSnapshot( - { - sourceId: 'default', - timerange: { - to: max, - from: min, - interval: '1m', - }, - metrics: [{ type: 'logRate' }], - nodeType: 'host', - groupBy: [{ field: 'host.name' }], - includeTimeseries: false, - }, - 400 - ); - - expect(resp).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: 'logRate metric is not supported without time series', - }); - }); }); describe('7.0.0', () => {