diff --git a/.buildkite/scripts/steps/artifacts/env.sh b/.buildkite/scripts/steps/artifacts/env.sh index 15f8f1df9fb28..ab12ea50e29cd 100755 --- a/.buildkite/scripts/steps/artifacts/env.sh +++ b/.buildkite/scripts/steps/artifacts/env.sh @@ -2,8 +2,8 @@ set -euo pipefail -RELEASE_BUILD="${RELEASE_BUILD:="false"}" -VERSION_QUALIFIER="${VERSION_QUALIFIER:=""}" +RELEASE_BUILD="true" +VERSION_QUALIFIER="" BASE_VERSION="$(jq -r '.version' package.json)" diff --git a/package.json b/package.json index 4f59c080e98d1..477ef4b57a148 100644 --- a/package.json +++ b/package.json @@ -571,9 +571,9 @@ "@kbn/index-management-plugin": "link:x-pack/plugins/index_management", "@kbn/index-management-shared-types": "link:x-pack/packages/index-management/index_management_shared_types", "@kbn/index-patterns-test-plugin": "link:test/plugin_functional/plugins/index_patterns", - "@kbn/inference_integration_flyout": "link:x-pack/packages/ml/inference_integration_flyout", "@kbn/inference-common": "link:x-pack/packages/ai-infra/inference-common", "@kbn/inference-plugin": "link:x-pack/plugins/inference", + "@kbn/inference_integration_flyout": "link:x-pack/packages/ml/inference_integration_flyout", "@kbn/infra-forge": "link:x-pack/packages/kbn-infra-forge", "@kbn/infra-plugin": "link:x-pack/plugins/observability_solution/infra", "@kbn/ingest-pipelines-plugin": "link:x-pack/plugins/ingest_pipelines", @@ -1125,6 +1125,7 @@ "expiry-js": "0.1.7", "exponential-backoff": "^3.1.1", "extract-zip": "^2.0.1", + "fast-content-type-parse": "^3.0.0", "fast-deep-equal": "^3.1.3", "fast-glob": "^3.3.2", "fastest-levenshtein": "^1.0.12", @@ -1843,4 +1844,4 @@ "zod-to-json-schema": "^3.23.0" }, "packageManager": "yarn@1.22.21" -} \ No newline at end of file +} diff --git a/src/dev/build/lib/version_info.ts b/src/dev/build/lib/version_info.ts index 980059eb55e53..061d74501509b 100644 --- a/src/dev/build/lib/version_info.ts +++ b/src/dev/build/lib/version_info.ts @@ -26,10 +26,7 @@ type ResolvedType> = T extends Promise ? X : nev export type VersionInfo = ResolvedType>; export async function getVersionInfo({ isRelease, versionQualifier, pkg }: Options) { - const buildVersion = pkg.version.concat( - versionQualifier ? `-${versionQualifier}` : '', - isRelease ? '' : '-SNAPSHOT' - ); + const buildVersion = pkg.version; const buildSha = fs.existsSync(join(REPO_ROOT, '.git')) ? (await execa('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT })).stdout diff --git a/src/dev/build/tasks/download_cloud_dependencies.ts b/src/dev/build/tasks/download_cloud_dependencies.ts index 46fb400a42a35..030bd6e31aa56 100644 --- a/src/dev/build/tasks/download_cloud_dependencies.ts +++ b/src/dev/build/tasks/download_cloud_dependencies.ts @@ -17,7 +17,7 @@ export const DownloadCloudDependencies: Task = { description: 'Downloading cloud dependencies', async run(config, log, build) { - const subdomain = config.isRelease ? 'artifacts-staging' : 'artifacts-snapshot'; + const subdomain = 'artifacts-staging'; const downloadBeat = async (beat: string, id: string) => { const version = config.getBuildVersion(); diff --git a/x-pack/plugins/observability_solution/infra/common/formatters/get_custom_metric_label.ts b/x-pack/plugins/observability_solution/infra/common/formatters/get_custom_metric_label.ts index 67c56e413922a..0a2f0924a020c 100644 --- a/x-pack/plugins/observability_solution/infra/common/formatters/get_custom_metric_label.ts +++ b/x-pack/plugins/observability_solution/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/plugins/observability_solution/infra/common/http_api/metrics_explorer.ts b/x-pack/plugins/observability_solution/infra/common/http_api/metrics_explorer.ts index d8c92b89f1337..c775c0b5a45b4 100644 --- a/x-pack/plugins/observability_solution/infra/common/http_api/metrics_explorer.ts +++ b/x-pack/plugins/observability_solution/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/plugins/observability_solution/infra/common/http_api/snapshot_api.ts b/x-pack/plugins/observability_solution/infra/common/http_api/snapshot_api.ts index 6707165314b9e..a70e1c30d1a0a 100644 --- a/x-pack/plugins/observability_solution/infra/common/http_api/snapshot_api.ts +++ b/x-pack/plugins/observability_solution/infra/common/http_api/snapshot_api.ts @@ -74,7 +74,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]; @@ -108,9 +108,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, filterQuery: rt.union([rt.string, rt.null]), diff --git a/x-pack/plugins/observability_solution/infra/common/inventory_views/types.ts b/x-pack/plugins/observability_solution/infra/common/inventory_views/types.ts index cae233c884f16..f97a0432ba37e 100644 --- a/x-pack/plugins/observability_solution/infra/common/inventory_views/types.ts +++ b/x-pack/plugins/observability_solution/infra/common/inventory_views/types.ts @@ -23,12 +23,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/plugins/observability_solution/infra/common/snapshot_metric_i18n.ts b/x-pack/plugins/observability_solution/infra/common/snapshot_metric_i18n.ts index 6e530a797c61f..e077d52ecbed3 100644 --- a/x-pack/plugins/observability_solution/infra/common/snapshot_metric_i18n.ts +++ b/x-pack/plugins/observability_solution/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/plugins/observability_solution/infra/public/alerting/inventory/components/expression_chart.tsx b/x-pack/plugins/observability_solution/infra/public/alerting/inventory/components/expression_chart.tsx index 52664a1f86f8f..d6ec756ba8624 100644 --- a/x-pack/plugins/observability_solution/infra/public/alerting/inventory/components/expression_chart.tsx +++ b/x-pack/plugins/observability_solution/infra/public/alerting/inventory/components/expression_chart.tsx @@ -80,6 +80,7 @@ export const ExpressionChart: React.FC = ({ accountId, region, timerange, + includeTimeseries: true, }); const metric = { diff --git a/x-pack/plugins/observability_solution/infra/public/alerting/inventory/components/metric.tsx b/x-pack/plugins/observability_solution/infra/public/alerting/inventory/components/metric.tsx index e112001b7f662..a5fb2bc2eaaed 100644 --- a/x-pack/plugins/observability_solution/infra/public/alerting/inventory/components/metric.tsx +++ b/x-pack/plugins/observability_solution/infra/public/alerting/inventory/components/metric.tsx @@ -36,7 +36,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 }>; @@ -68,22 +68,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/plugins/observability_solution/infra/public/common/inventory/types.ts b/x-pack/plugins/observability_solution/infra/public/common/inventory/types.ts index d4d4eca3902bb..5f2d0401ff3ad 100644 --- a/x-pack/plugins/observability_solution/infra/public/common/inventory/types.ts +++ b/x-pack/plugins/observability_solution/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/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/layout.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/layout.tsx index df5dd3dbe5973..8ad912265d98d 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/layout.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/layout.tsx @@ -65,15 +65,16 @@ export const Layout = React.memo(({ currentView, reload, interval, nodes, loadin 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 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/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx index a4d18eba34aef..46102d541bfca 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx @@ -114,6 +114,7 @@ describe('ConditionalToolTip', () => { groupBy: [], nodeType: 'host', sourceId: 'default', + includeTimeseries: true, currentTime, accountId: '', region: '', diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx index 1d35503cf66f9..5853d54d6867b 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.tsx @@ -61,6 +61,7 @@ export const ConditionalToolTip = ({ node, nodeType, currentTime }: Props) => { currentTime: requestCurrentTime.current, accountId: '', region: '', + includeTimeseries: true, }); const dataNode = first(nodes); diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.stories.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.stories.tsx new file mode 100644 index 0000000000000..871bf719651bc --- /dev/null +++ b/x-pack/plugins/observability_solution/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/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx index 7b81056b32b8f..8e880a6ce2e0a 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx @@ -8,6 +8,7 @@ import { EuiButtonEmpty, EuiButton, + EuiButtonGroup, EuiButtonIcon, EuiFieldNumber, EuiForm, @@ -21,13 +22,15 @@ import { EuiRange, EuiFlexGroup, EuiFlexItem, + useEuiTheme, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; -import React, { SyntheticEvent, useState, useCallback, useEffect } from 'react'; -import { first, last } from 'lodash'; -import { EuiRangeProps, EuiSelectProps } from '@elastic/eui'; +import type { SyntheticEvent } from 'react'; +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 { type InfraWaffleMapBounds, @@ -37,9 +40,10 @@ import { import { getColorPalette } from '../../lib/get_color_palette'; import { convertBoundsToPercents } from '../../lib/convert_bounds_to_percents'; import { SwatchLabel } from './swatch_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; @@ -63,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, @@ -70,16 +99,37 @@ 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.success, label: 'OK', value: 0 }, + { color: euiTheme.colors.warning, label: 'WARNING', value: 1 }, + { color: euiTheme.colors.danger, label: 'CRITICAL', value: 2 }, + { color: euiTheme.colors.mediumShade, label: 'UNKNOWN', value: 3 }, + ], + [ + euiTheme.colors.success, + euiTheme.colors.warning, + euiTheme.colors.danger, + euiTheme.colors.mediumShade, + ] + ); + 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" /> ); @@ -98,94 +148,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' && ( + + )} @@ -362,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/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/legend_steps.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/legend_steps.tsx new file mode 100644 index 0000000000000..df3c328b3ac43 --- /dev/null +++ b/x-pack/plugins/observability_solution/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/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/metric_control/custom_metric_form.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/metric_control/custom_metric_form.tsx index 00da4640eb73f..890a3deacbd09 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/metric_control/custom_metric_form.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/metric_control/custom_metric_form.tsx @@ -7,6 +7,7 @@ import React, { useState, useCallback } from 'react'; import { v4 as uuidv4 } from 'uuid'; +import type { WithEuiThemeProps } from '@elastic/eui'; import { EuiForm, EuiButton, @@ -19,236 +20,230 @@ import { EuiFlexItem, EuiText, EuiPopoverTitle, + withEuiTheme, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiTheme, withTheme } from '@kbn/kibana-react-plugin/common'; -import { +import type { SnapshotCustomAggregation, SnapshotCustomMetricInput, +} from '../../../../../../../common/http_api/snapshot_api'; +import { SNAPSHOT_CUSTOM_AGGREGATIONS, 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 { - theme: EuiTheme | undefined; metric?: SnapshotCustomMetricInput; customMetrics: SnapshotCustomMetricInput[]; onChange: (metric: SnapshotCustomMetricInput) => void; onCancel: () => void; } -export const CustomMetricForm = withTheme(({ theme, onCancel, onChange, metric }: Props) => { - const { metricsView } = useMetricsDataViewContext(); - const [label, setLabel] = useState(metric ? metric.label : void 0); - const [aggregation, setAggregation] = useState( - metric ? metric.aggregation : 'avg' - ); - const [field, setField] = useState(metric ? metric.field : void 0); +type PropsWithTheme = Props & WithEuiThemeProps; - const handleSubmit = useCallback(() => { - if (metric && aggregation && field) { - onChange({ - ...metric, - label, - aggregation, - field, - }); - } else if (aggregation && field) { - const newMetric: SnapshotCustomMetricInput = { - type: 'custom', - id: uuidv4(), - label, - aggregation, - field, - }; - onChange(newMetric); - } - }, [metric, aggregation, field, onChange, label]); +export const CustomMetricForm = withEuiTheme( + ({ theme, onCancel, onChange, metric }: PropsWithTheme) => { + const { metricsView } = useMetricsDataViewContext(); + const [label, setLabel] = useState(metric ? metric.label : void 0); + const [aggregation, setAggregation] = useState( + metric ? metric.aggregation : 'avg' + ); + const [field, setField] = useState(metric ? metric.field : void 0); - const handleLabelChange = useCallback( - (e: React.ChangeEvent) => { - setLabel(e.target.value); - }, - [setLabel] - ); + const handleSubmit = useCallback(() => { + if (metric && aggregation && field) { + onChange({ + ...metric, + label, + aggregation, + field, + }); + } else if (aggregation && field) { + const newMetric: SnapshotCustomMetricInput = { + type: 'custom', + id: uuidv4(), + label, + aggregation, + field, + }; + onChange(newMetric); + } + }, [metric, aggregation, field, onChange, label]); - const handleFieldChange = useCallback( - (selectedOptions: SelectedOption[]) => { - setField(selectedOptions[0].label); - }, - [setField] - ); + const handleLabelChange = useCallback( + (e: React.ChangeEvent) => { + setLabel(e.target.value); + }, + [setLabel] + ); - const handleAggregationChange = useCallback( - (e: React.ChangeEvent) => { - const value = e.target.value; - const aggValue: SnapshotCustomAggregation = SnapshotCustomAggregationRT.is(value) - ? value - : 'avg'; - setAggregation(aggValue); - }, - [setAggregation] - ); + const handleFieldChange = useCallback( + (selectedOptions: SelectedOption[]) => { + setField(selectedOptions[0].label); + }, + [setField] + ); - const fieldOptions = (metricsView?.fields ?? []) - .filter((f) => f.aggregatable && f.type === 'number' && !(field && field === f.name)) - .map((f) => ({ label: f.name })); + const handleAggregationChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + const aggValue: SnapshotCustomAggregation = SnapshotCustomAggregationRT.is(value) + ? value + : 'avg'; + setAggregation(aggValue); + }, + [setAggregation] + ); - const aggregationOptions = SNAPSHOT_CUSTOM_AGGREGATIONS.map((k) => ({ - text: AGGREGATION_LABELS[k as SnapshotCustomAggregation], - value: k, - })); + const fieldOptions = (metricsView?.fields ?? []) + .filter((f) => f.aggregatable && f.type === 'number' && !(field && field === f.name)) + .map((f) => ({ label: f.name })); - const isSubmitDisabled = !field || !aggregation; + const aggregationOptions = SNAPSHOT_CUSTOM_AGGREGATIONS.map((k) => ({ + text: AGGREGATION_LABELS[k as SnapshotCustomAggregation], + value: k, + })); - const title = metric - ? i18n.translate('xpack.infra.waffle.customMetricPanelLabel.edit', { - defaultMessage: 'Edit custom metric', - }) - : i18n.translate('xpack.infra.waffle.customMetricPanelLabel.add', { - defaultMessage: 'Add custom metric', - }); + const isSubmitDisabled = !field || !aggregation; - const titleAriaLabel = metric - ? i18n.translate('xpack.infra.waffle.customMetricPanelLabel.editAriaLabel', { - defaultMessage: 'Back to custom metrics edit mode', - }) - : i18n.translate('xpack.infra.waffle.customMetricPanelLabel.addAriaLabel', { - defaultMessage: 'Back to metric picker', - }); + const title = metric + ? i18n.translate('xpack.infra.waffle.customMetricPanelLabel.edit', { + defaultMessage: 'Edit custom metric', + }) + : i18n.translate('xpack.infra.waffle.customMetricPanelLabel.add', { + defaultMessage: 'Add custom metric', + }); - return ( -
- - - - {title} - - -
- - - - - - - - - {i18n.translate('xpack.infra.waffle.customMetrics.ofLabel', { - defaultMessage: 'of', - })} - - - - - - - - - + + + + {title} + + +
- - -
-
- - - - - - -
-
-
- ); -}); + > + + + + + + + + {i18n.translate('xpack.infra.waffle.customMetrics.ofLabel', { + defaultMessage: 'of', + })} + + + + + + + + + + + +
+
+ + + + + + +
+ + + ); + } +); diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/steps_legend.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/steps_legend.tsx index 23935caedcd40..0cfa1f9eb29db 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/components/waffle/steps_legend.tsx +++ b/x-pack/plugins/observability_solution/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 = euiStyled.div` display: flex; + flex-direction: column; padding: 10px 40px 10px 10px; + max-height: 50vh; + overflow-y: auto; `; const StepContainer = euiStyled.div` display: flex; - margin-right: 20px + margin-bottom: 8px; align-items: center; + + &:last-child { + margin-bottom: 0; + } `; const StepSquare = euiStyled.div` @@ -65,6 +71,7 @@ const StepSquare = euiStyled.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/plugins/observability_solution/infra/public/pages/metrics/inventory_view/hooks/use_snaphot.ts b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/hooks/use_snaphot.ts index 0f16700b14932..560b91f411b2c 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/hooks/use_snaphot.ts +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/hooks/use_snaphot.ts @@ -15,10 +15,9 @@ import { } from '../../../../../common/http_api/snapshot_api'; export interface UseSnapshotRequest - extends Omit { + extends Omit { filterQuery?: string | null | symbol; currentTime: number; - includeTimeseries?: boolean; timerange?: InfraTimerangeInput; } @@ -59,7 +58,7 @@ const buildPayload = (args: UseSnapshotRequest): SnapshotRequest => { dropPartialBuckets = true, filterQuery = '', groupBy = null, - includeTimeseries = true, + includeTimeseries, metrics, nodeType, overrideCompositeSize, diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts index 41b91bce9c4ee..0455ee94d8bc9 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts @@ -29,6 +29,7 @@ import type { export const DEFAULT_LEGEND: WaffleLegendOptions = { palette: 'cool', steps: 10, + rules: [], reverseColors: false, }; diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/lib/color_from_value.ts b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/lib/color_from_value.ts index e9be23a064320..1b0c8c7d79bbd 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/lib/color_from_value.ts +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/lib/color_from_value.ts @@ -90,7 +90,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/plugins/observability_solution/infra/public/pages/metrics/inventory_view/lib/create_legend.ts b/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/lib/create_legend.ts index bf30ec9b94774..7dfd95f65ebce 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/inventory_view/lib/create_legend.ts +++ b/x-pack/plugins/observability_solution/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/plugins/observability_solution/infra/public/pages/metrics/metrics_explorer/components/aggregation.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/metrics_explorer/components/aggregation.tsx index 3b37c2aa36938..75cd515b3f776 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/metrics_explorer/components/aggregation.tsx +++ b/x-pack/plugins/observability_solution/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 ( ; -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/plugins/observability_solution/infra/server/lib/metrics/lib/create_aggregations.ts b/x-pack/plugins/observability_solution/infra/server/lib/metrics/lib/create_aggregations.ts index 13987761d72d6..801a3385817f6 100644 --- a/x-pack/plugins/observability_solution/infra/server/lib/metrics/lib/create_aggregations.ts +++ b/x-pack/plugins/observability_solution/infra/server/lib/metrics/lib/create_aggregations.ts @@ -9,6 +9,7 @@ import { AggregationOptionsByType } from '@kbn/es-types'; import Boom from '@hapi/boom'; import { type MetricsAPIRequest } from '@kbn/metrics-data-access-plugin/common'; +import { isDerivativeAgg } from '@kbn/metrics-data-access-plugin/common/inventory_models'; import { afterKeyObjectRT } from '../../../../common/http_api'; import { TIMESTAMP_FIELD } from '../../../../common/constants'; import { calculateDateHistogramOffset } from './calculate_date_histogram_offset'; @@ -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/plugins/observability_solution/infra/server/lib/metrics/types.ts b/x-pack/plugins/observability_solution/infra/server/lib/metrics/types.ts index 0c87e8eca47d9..18253c557912e 100644 --- a/x-pack/plugins/observability_solution/infra/server/lib/metrics/types.ts +++ b/x-pack/plugins/observability_solution/infra/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/plugins/observability_solution/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts b/x-pack/plugins/observability_solution/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts index 517b97ee809a3..4eae6c7474e56 100644 --- a/x-pack/plugins/observability_solution/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts +++ b/x-pack/plugins/observability_solution/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts @@ -11,6 +11,7 @@ import { findInventoryFields, findInventoryModel, } from '@kbn/metrics-data-access-plugin/common'; +import { isDerivativeAgg } from '@kbn/metrics-data-access-plugin/common/inventory_models'; import { TIMESTAMP_FIELD } from '../../../../common/constants'; import { SnapshotRequest } from '../../../../common/http_api'; import { ESSearchClient } from '../../../lib/metrics/types'; @@ -40,6 +41,14 @@ export const transformRequestToMetricsAPIRequest = async ({ sourceConfiguration: source.configuration, }); + const transformed = await transformSnapshotMetricsToMetricsAPIMetrics(snapshotRequest); + + const includeTimeseries = + snapshotRequest.includeTimeseries || + Object.values(transformed).some((metric) => + Object.values(metric.aggregations).some(isDerivativeAgg) + ); + const metricsApiRequest: MetricsAPIRequest = { indexPattern: sourceOverrides?.indexPattern ?? source.configuration.metricAlias, timerange: { @@ -51,7 +60,7 @@ export const transformRequestToMetricsAPIRequest = async ({ : compositeSize, alignDataToEnd: true, dropPartialBuckets: snapshotRequest.dropPartialBuckets ?? true, - includeTimeseries: snapshotRequest.includeTimeseries, + includeTimeseries, }; const filters = []; diff --git a/x-pack/plugins/observability_solution/infra/server/routes/snapshot/lib/transform_snapshot_metrics_to_metrics_api_metrics.ts b/x-pack/plugins/observability_solution/infra/server/routes/snapshot/lib/transform_snapshot_metrics_to_metrics_api_metrics.ts index 2a5adbbe9d799..a7c016b253c5f 100644 --- a/x-pack/plugins/observability_solution/infra/server/routes/snapshot/lib/transform_snapshot_metrics_to_metrics_api_metrics.ts +++ b/x-pack/plugins/observability_solution/infra/server/routes/snapshot/lib/transform_snapshot_metrics_to_metrics_api_metrics.ts @@ -22,19 +22,41 @@ export const transformSnapshotMetricsToMetricsAPIMetrics = ( 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 }; }) diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 414dd772419f0..783baae6b7cb4 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -24871,16 +24871,16 @@ "xpack.infra.metrics.settingsTabTitle": "Paramètres", "xpack.infra.metricsExplorer.actionsLabel.aria": "Actions pour {grouping}", "xpack.infra.metricsExplorer.actionsLabel.button": "Actions", - "xpack.infra.metricsExplorer.aggregationLabel": "de", - "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.aggregationLabel": "sur", + "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 \"", @@ -25148,10 +25148,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}", @@ -50090,4 +50090,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/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c00034b16d208..22c78a1f1c3c6 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -24845,15 +24845,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": "\"および\"", @@ -25120,10 +25120,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} のカスタムメトリックを編集", @@ -50048,4 +50048,4 @@ "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "フィールドを選択してください。", "xpack.watcher.watcherDescription": "アラートの作成、管理、監視によりデータへの変更を検知します。" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 9b6b5c62f1c7b..1b97a24fdc76d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -24900,15 +24900,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": "\" 且 \"", @@ -25176,10 +25176,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} 的定制指标", @@ -50127,4 +50127,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/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 new file mode 100644 index 0000000000000..9808e74a19a92 --- /dev/null +++ b/x-pack/solutions/observability/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts @@ -0,0 +1,283 @@ +/* + * 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 { useCallback, useEffect, useRef, useState } from 'react'; +import { pipe } from 'fp-ts/pipeable'; +import { fold } from 'fp-ts/Either'; +import { constant, identity } from 'fp-ts/function'; +import createContainer from 'constate'; +import type { DataSchemaFormat } from '@kbn/metrics-data-access-plugin/common'; +import { type InventoryItemType } from '@kbn/metrics-data-access-plugin/common'; +import { useUrlState } from '@kbn/observability-shared-plugin/public'; +import { useInfraMLCapabilitiesContext } from '../../../../containers/ml/infra_ml_capabilities'; +import type { + InventoryView, + InventoryViewOptions, +} from '../../../../../common/inventory_views/types'; +import { + type InventoryLegendOptions, + type InventoryOptionsState, + type InventorySortOption, + inventoryOptionsStateRT, + staticInventoryViewId, +} from '../../../../../common/inventory_views'; +import { useAlertPrefillContext } from '../../../../alerting/use_alert_prefill'; +import type { + SnapshotMetricInput, + SnapshotGroupBy, + SnapshotCustomMetricInput, +} from '../../../../../common/http_api/snapshot_api'; +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 = { + metric: { type: 'cpuV2' }, + groupBy: [], + nodeType: 'host', + view: 'map', + customOptions: [], + boundsOverride: { max: 1, min: 0 }, + autoBounds: true, + accountId: '', + region: '', + customMetrics: [], + legend: DEFAULT_LEGEND, + source: 'default', + sort: { by: 'name', direction: 'desc' }, + timelineOpen: false, + preferredSchema: null, +}; + +function mapInventoryViewToState(savedView: InventoryView): WaffleOptionsState { + const { + metric, + groupBy, + nodeType, + view, + customOptions, + autoBounds, + boundsOverride, + accountId, + region, + customMetrics, + legend, + sort, + timelineOpen, + preferredSchema, + } = savedView.attributes; + + // forces the default view to be set with what the time range metadata endpoint returns + const preferredSchemaValue = + nodeType === 'host' && savedView.id === staticInventoryViewId + ? preferredSchema ?? null + : // otherwise, use the preferred schema from the saved view + preferredSchema; + + return { + metric, + groupBy, + nodeType, + view, + customOptions, + autoBounds, + boundsOverride, + accountId, + region, + customMetrics, + legend, + sort, + timelineOpen, + preferredSchema: preferredSchemaValue, + }; +} + +export const useWaffleOptions = () => { + const { currentView } = useInventoryViewsContext(); + const { + inventoryPrefill: { setPrefillState }, + } = useAlertPrefillContext(); + + const { updateTopbarMenuVisibilityBySchema } = useInfraMLCapabilitiesContext(); + const [urlState, setUrlState] = useUrlState({ + defaultState: currentView ? mapInventoryViewToState(currentView) : DEFAULT_WAFFLE_OPTIONS_STATE, + decodeUrlState, + encodeUrlState, + urlStateKey: 'waffleOptions', + writeDefaultState: true, + }); + + const [preferredSchema, setPreferredSchema] = useState(null); + + const previousViewId = useRef(currentView?.id ?? staticInventoryViewId); + useEffect(() => { + if (currentView && currentView.id !== previousViewId.current) { + const state = mapInventoryViewToState(currentView); + updateTopbarMenuVisibilityBySchema(state.preferredSchema); + setUrlState(state); + previousViewId.current = currentView.id; + + setPreferredSchema(currentView?.attributes.preferredSchema ?? null); + } + }, [currentView, setUrlState, updateTopbarMenuVisibilityBySchema]); + + // there is a lot going on with the url state management on this hook + // when the state resets, many things need to be synchronized + // to avoid problems, we sync the url state manually. + useEffect(() => { + if (urlState.preferredSchema !== preferredSchema) { + setUrlState((previous) => ({ ...previous, preferredSchema })); + } + }, [preferredSchema, setUrlState, urlState.preferredSchema]); + + const changeMetric = useCallback( + (metric: SnapshotMetricInput) => setUrlState((previous) => ({ ...previous, metric })), + [setUrlState] + ); + + const changeGroupBy = useCallback( + (groupBy: SnapshotGroupBy) => setUrlState((previous) => ({ ...previous, groupBy })), + [setUrlState] + ); + + const changeNodeType = useCallback( + (nodeType: InventoryItemType) => setUrlState((previous) => ({ ...previous, nodeType })), + [setUrlState] + ); + + const changeView = useCallback( + (view: string) => + setUrlState((previous) => ({ ...previous, view: view as InventoryViewOptions })), + [setUrlState] + ); + + const changeCustomOptions = useCallback( + (customOptions: Array<{ text: string; field: string }>) => + setUrlState((previous) => ({ ...previous, customOptions })), + [setUrlState] + ); + + const changeAutoBounds = useCallback( + (autoBounds: boolean) => setUrlState((previous) => ({ ...previous, autoBounds })), + [setUrlState] + ); + + const changeBoundsOverride = useCallback( + (boundsOverride: { min: number; max: number }) => + setUrlState((previous) => ({ ...previous, boundsOverride })), + [setUrlState] + ); + + const changeAccount = useCallback( + (accountId: string) => setUrlState((previous) => ({ ...previous, accountId })), + [setUrlState] + ); + + const changeRegion = useCallback( + (region: string) => setUrlState((previous) => ({ ...previous, region })), + [setUrlState] + ); + + const changeCustomMetrics = useCallback( + (customMetrics: SnapshotCustomMetricInput[]) => { + setUrlState((previous) => ({ ...previous, customMetrics })); + }, + [setUrlState] + ); + + const changeLegend = useCallback( + (legend: WaffleLegendOptions) => { + setUrlState((previous) => ({ ...previous, legend })); + }, + [setUrlState] + ); + + const changeSort = useCallback( + (sort: WaffleSortOption) => { + setUrlState((previous) => ({ ...previous, sort })); + }, + [setUrlState] + ); + + const changePreferredSchema = useCallback( + (schema: DataSchemaFormat | null) => { + // the URL state can't be patched here because when the page reloads via clicking on the side nav + // this will be called before the hydration of the URL state, causing the page to crash + setPreferredSchema(schema); + updateTopbarMenuVisibilityBySchema(schema); + }, + [setPreferredSchema, updateTopbarMenuVisibilityBySchema] + ); + + useEffect(() => { + setPrefillState({ + nodeType: urlState.nodeType, + metric: urlState.metric, + customMetrics: urlState.customMetrics, + accountId: urlState.accountId, + region: urlState.region, + schema: urlState.preferredSchema, + }); + }, [ + setPrefillState, + urlState.accountId, + urlState.customMetrics, + urlState.metric, + urlState.nodeType, + urlState.preferredSchema, + urlState.region, + ]); + + const changeTimelineOpen = useCallback( + (timelineOpen: boolean) => setUrlState((previous) => ({ ...previous, timelineOpen })), + [setUrlState] + ); + + return { + ...urlState, + changeMetric, + changeGroupBy, + changeNodeType, + changeView, + changeCustomOptions, + changeAutoBounds, + changeBoundsOverride, + changeAccount, + changeRegion, + changeCustomMetrics, + changeLegend, + changeSort, + changeTimelineOpen, + changePreferredSchema, + setWaffleOptionsState: setUrlState, + }; +}; + +export type WaffleLegendOptions = InventoryLegendOptions; +export type WaffleSortOption = InventorySortOption; +export type WaffleOptionsState = InventoryOptionsState; + +const encodeUrlState = (state: InventoryOptionsState) => { + return inventoryOptionsStateRT.encode(state); +}; + +const decodeUrlState = (value: unknown) => { + const state = pipe(inventoryOptionsStateRT.decode(value), fold(constant(undefined), identity)); + if (state) { + state.source = 'url'; + } + return state; +}; + +export const WaffleOptions = createContainer(useWaffleOptions); +export const [WaffleOptionsProvider, useWaffleOptionsContext] = WaffleOptions; diff --git a/x-pack/test/api_integration/deployment_agnostic/apis/observability/infra/snapshot.ts b/x-pack/test/api_integration/deployment_agnostic/apis/observability/infra/snapshot.ts index f3636662f2b54..1569db9c75717 100644 --- a/x-pack/test/api_integration/deployment_agnostic/apis/observability/infra/snapshot.ts +++ b/x-pack/test/api_integration/deployment_agnostic/apis/observability/infra/snapshot.ts @@ -296,7 +296,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { expect(resp).to.eql({ statusCode: 400, error: 'Bad Request', - message: 'logRate metric is not supported without time series', + message: 'The following metrics require time series: logRate', }); }); }); diff --git a/yarn.lock b/yarn.lock index af7dd9cb3402f..fcffedd35f891 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18938,6 +18938,11 @@ fancy-log@^1.3.3: parse-node-version "^1.0.0" time-stamp "^1.0.0" +fast-content-type-parse@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz#5590b6c807cc598be125e6740a9fde589d2b7afb" + integrity sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg== + fast-deep-equal@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" @@ -30643,7 +30648,7 @@ string-replace-loader@^3.1.0: loader-utils "^2.0.0" schema-utils "^3.0.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -30661,6 +30666,15 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -30771,7 +30785,7 @@ stringify-object@^3.2.1: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -30785,6 +30799,13 @@ strip-ansi@^3.0.0, strip-ansi@^3.0.1: dependencies: ansi-regex "^2.0.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -33739,7 +33760,7 @@ workerpool@^6.5.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -33765,6 +33786,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -33877,7 +33907,7 @@ xpath@^0.0.33: resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.33.tgz#5136b6094227c5df92002e7c3a13516a5074eb07" integrity sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA== -"xstate5@npm:xstate@^5.18.1", xstate@^5.18.1: +"xstate5@npm:xstate@^5.18.1": version "5.19.2" resolved "https://registry.yarnpkg.com/xstate/-/xstate-5.19.2.tgz#db3f1ee614bbb6a49ad3f0c96ddbf98562d456ba" integrity sha512-B8fL2aP0ogn5aviAXFzI5oZseAMqN00fg/TeDa3ZtatyDcViYLIfuQl4y8qmHCiKZgGEzmnTyNtNQL9oeJE2gw== @@ -33887,6 +33917,11 @@ xstate@^4.38.2: resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.38.3.tgz#4e15e7ad3aa0ca1eea2010548a5379966d8f1075" integrity sha512-SH7nAaaPQx57dx6qvfcIgqKRXIh4L0A1iYEqim4s1u7c9VoCgzZc+63FY90AKU4ZzOC2cfJzTnpO4zK7fCUzzw== +xstate@^5.18.1: + version "5.19.2" + resolved "https://registry.yarnpkg.com/xstate/-/xstate-5.19.2.tgz#db3f1ee614bbb6a49ad3f0c96ddbf98562d456ba" + integrity sha512-B8fL2aP0ogn5aviAXFzI5oZseAMqN00fg/TeDa3ZtatyDcViYLIfuQl4y8qmHCiKZgGEzmnTyNtNQL9oeJE2gw== + "xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2, xtend@~4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"