From 78db3eaffe7f40a27606edf2a3d1f1801c6f239a Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Thu, 21 May 2020 11:47:08 -0700 Subject: [PATCH] [Metrics UI] Allow users to configure Inventory View palettes (#66948) --- .../common/saved_objects/inventory_view.ts | 13 + x-pack/plugins/infra/public/lib/lib.ts | 87 ++++- .../inventory_view/components/layout.tsx | 16 +- .../components/toolbars/toolbar_wrapper.tsx | 2 + .../components/waffle/legend.tsx | 39 +- .../components/waffle/legend_controls.tsx | 350 ++++++++++++++---- .../components/waffle/palette_preview.tsx | 44 +++ .../waffle/stepped_gradient_legend.tsx | 106 ++++++ .../components/waffle/swatch_label.tsx | 28 ++ .../hooks/use_waffle_options.ts | 23 ++ .../hooks/use_waffle_view_state.ts | 3 + .../inventory_view/lib/color_from_value.ts | 60 ++- .../lib/convert_bounds_to_percents.ts | 11 + .../inventory_view/lib/create_legend.ts | 23 ++ .../inventory_view/lib/get_color_palette.ts | 40 ++ .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 17 files changed, 710 insertions(+), 137 deletions(-) create mode 100644 x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/palette_preview.tsx create mode 100644 x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/stepped_gradient_legend.tsx create mode 100644 x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/swatch_label.tsx create mode 100644 x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/convert_bounds_to_percents.ts create mode 100644 x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_legend.ts create mode 100644 x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/get_color_palette.ts diff --git a/x-pack/plugins/infra/common/saved_objects/inventory_view.ts b/x-pack/plugins/infra/common/saved_objects/inventory_view.ts index 15874f2eee34..cca838e526e6 100644 --- a/x-pack/plugins/infra/common/saved_objects/inventory_view.ts +++ b/x-pack/plugins/infra/common/saved_objects/inventory_view.ts @@ -50,6 +50,19 @@ export const inventoryViewSavedObjectType: SavedObjectsType = { }, }, }, + legend: { + properties: { + palette: { + type: 'keyword', + }, + steps: { + type: 'long', + }, + reverseColors: { + type: 'boolean', + }, + }, + }, groupBy: { type: 'nested', properties: { diff --git a/x-pack/plugins/infra/public/lib/lib.ts b/x-pack/plugins/infra/public/lib/lib.ts index 6bd4c10c881e..d1ca62b747a2 100644 --- a/x-pack/plugins/infra/public/lib/lib.ts +++ b/x-pack/plugins/infra/public/lib/lib.ts @@ -10,6 +10,8 @@ import ApolloClient from 'apollo-client'; import { AxiosRequestConfig } from 'axios'; import React from 'react'; import { Observable } from 'rxjs'; +import * as rt from 'io-ts'; +import { i18n } from '@kbn/i18n'; import { SourceQuery } from '../graphql/types'; import { SnapshotMetricInput, @@ -125,29 +127,76 @@ export interface InfraWaffleMapGroupOfNodes extends InfraWaffleMapGroupBase { nodes: InfraWaffleMapNode[]; } -export interface InfraWaffleMapStepRule { - value: number; - operator: InfraWaffleMapRuleOperator; - color: string; - label?: string; -} +export const OperatorRT = rt.keyof({ + gt: null, + gte: null, + lt: null, + lte: null, + eq: null, +}); -export interface InfraWaffleMapGradientRule { - value: number; - color: string; -} +export const PALETTES = { + status: i18n.translate('xpack.infra.legendControls.palettes.status', { + defaultMessage: 'Status', + }), + temperature: i18n.translate('xpack.infra.legendControls.palettes.temperature', { + defaultMessage: 'Temperature', + }), + cool: i18n.translate('xpack.infra.legendControls.palettes.cool', { + defaultMessage: 'Cool', + }), + warm: i18n.translate('xpack.infra.legendControls.palettes.warm', { + defaultMessage: 'Warm', + }), + positive: i18n.translate('xpack.infra.legendControls.palettes.positive', { + defaultMessage: 'Positive', + }), + negative: i18n.translate('xpack.infra.legendControls.palettes.negative', { + defaultMessage: 'Negative', + }), +}; -export interface InfraWaffleMapStepLegend { - type: 'step'; - rules: InfraWaffleMapStepRule[]; -} +export const InventoryColorPaletteRT = rt.keyof(PALETTES); +export type InventoryColorPalette = rt.TypeOf; -export interface InfraWaffleMapGradientLegend { - type: 'gradient'; - rules: InfraWaffleMapGradientRule[]; -} +export const StepRuleRT = rt.intersection([ + rt.type({ + value: rt.number, + operator: OperatorRT, + color: rt.string, + }), + rt.partial({ label: rt.string }), +]); -export type InfraWaffleMapLegend = InfraWaffleMapStepLegend | InfraWaffleMapGradientLegend; +export const StepLegendRT = rt.type({ + type: rt.literal('step'), + rules: rt.array(StepRuleRT), +}); +export type InfraWaffleMapStepRule = rt.TypeOf; +export type InfraWaffleMapStepLegend = rt.TypeOf; + +export const GradientRuleRT = rt.type({ + value: rt.number, + color: rt.string, +}); + +export const GradientLegendRT = rt.type({ + type: rt.literal('gradient'), + rules: rt.array(GradientRuleRT), +}); + +export type InfraWaffleMapGradientRule = rt.TypeOf; +export type InfraWaffleMapGradientLegend = rt.TypeOf; + +export const SteppedGradientLegendRT = rt.type({ + type: rt.literal('steppedGradient'), + rules: rt.array(GradientRuleRT), +}); + +export type InfraWaffleMapSteppedGradientLegend = rt.TypeOf; + +export const LegendRT = rt.union([StepLegendRT, GradientLegendRT, SteppedGradientLegendRT]); +export type InfraWaffleMapLegend = rt.TypeOf; export enum InfraWaffleMapRuleOperator { gt = 'gt', diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx index 32f8e47fe9b7..e89d533c9f10 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/layout.tsx @@ -7,7 +7,7 @@ import React, { useCallback } from 'react'; import { useInterval } from 'react-use'; -import { euiPaletteColorBlind, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { convertIntervalToString } from '../../../../utils/convert_interval_to_string'; import { NodesOverview, calculateBoundsFromNodes } from './nodes_overview'; import { PageContent } from '../../../../components/page'; @@ -16,7 +16,7 @@ import { useWaffleTimeContext } from '../hooks/use_waffle_time'; import { useWaffleFiltersContext } from '../hooks/use_waffle_filters'; import { useWaffleOptionsContext } from '../hooks/use_waffle_options'; import { useSourceContext } from '../../../../containers/source'; -import { InfraFormatterType, InfraWaffleMapGradientLegend } from '../../../../lib/lib'; +import { InfraFormatterType } from '../../../../lib/lib'; import { euiStyled } from '../../../../../../observability/public'; import { Toolbar } from './toolbars/toolbar'; import { ViewSwitcher } from './waffle/view_switcher'; @@ -24,8 +24,7 @@ import { SavedViews } from './saved_views'; import { IntervalLabel } from './waffle/interval_label'; import { Legend } from './waffle/legend'; import { createInventoryMetricFormatter } from '../lib/create_inventory_metric_formatter'; - -const euiVisColorPalette = euiPaletteColorBlind(); +import { createLegend } from '../lib/create_legend'; export const Layout = () => { const { sourceId, source } = useSourceContext(); @@ -40,6 +39,7 @@ export const Layout = () => { view, autoBounds, boundsOverride, + legend, } = useWaffleOptionsContext(); const { currentTime, jumpToTime, isAutoReloading } = useWaffleTimeContext(); const { filterQueryAsJson, applyFilterQuery } = useWaffleFiltersContext(); @@ -57,13 +57,7 @@ export const Layout = () => { const options = { formatter: InfraFormatterType.percent, formatTemplate: '{{value}}', - legend: { - type: 'gradient', - rules: [ - { value: 0, color: '#D3DAE6' }, - { value: 1, color: euiVisColorPalette[1] }, - ], - } as InfraWaffleMapGradientLegend, + legend: createLegend(legend.palette, legend.steps, legend.reverseColors), metric, sort, fields: source?.configuration?.fields, diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar_wrapper.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar_wrapper.tsx index 3606bcc6944d..7dc92c7a56ab 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar_wrapper.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/toolbars/toolbar_wrapper.tsx @@ -33,6 +33,7 @@ export const ToolbarWrapper = (props: Props) => { accountId, view, region, + legend, sort, customMetrics, changeCustomMetrics, @@ -59,6 +60,7 @@ export const ToolbarWrapper = (props: Props) => { nodeType, region, accountId, + legend, customMetrics, changeCustomMetrics, })} diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend.tsx index ac699f96a75a..c211de8fd3d2 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend.tsx @@ -3,15 +3,22 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { euiStyled } from '../../../../../../../observability/public'; -import { InfraFormatter, InfraWaffleMapBounds, InfraWaffleMapLegend } from '../../../../../lib/lib'; +import { + InfraFormatter, + InfraWaffleMapBounds, + InfraWaffleMapLegend, + SteppedGradientLegendRT, + StepLegendRT, + GradientLegendRT, +} from '../../../../../lib/lib'; import { GradientLegend } from './gradient_legend'; import { LegendControls } from './legend_controls'; -import { isInfraWaffleMapGradientLegend, isInfraWaffleMapStepLegend } from '../../lib/type_guards'; import { StepLegend } from './steps_legend'; -import { useWaffleOptionsContext } from '../../hooks/use_waffle_options'; +import { useWaffleOptionsContext, WaffleLegendOptions } from '../../hooks/use_waffle_options'; +import { SteppedGradientLegend } from './stepped_gradient_legend'; interface Props { legend: InfraWaffleMapLegend; bounds: InfraWaffleMapBounds; @@ -22,6 +29,7 @@ interface Props { interface LegendControlOptions { auto: boolean; bounds: InfraWaffleMapBounds; + legend: WaffleLegendOptions; } export const Legend: React.FC = ({ dataBounds, legend, bounds, formatter }) => { @@ -29,24 +37,35 @@ export const Legend: React.FC = ({ dataBounds, legend, bounds, formatter changeBoundsOverride, changeAutoBounds, autoBounds, + legend: legendOptions, + changeLegend, boundsOverride, } = useWaffleOptionsContext(); + const handleChange = useCallback( + (options: LegendControlOptions) => { + changeBoundsOverride(options.bounds); + changeAutoBounds(options.auto); + changeLegend(options.legend); + }, + [changeBoundsOverride, changeAutoBounds, changeLegend] + ); return ( { - changeBoundsOverride(options.bounds); - changeAutoBounds(options.auto); - }} + onChange={handleChange} /> - {isInfraWaffleMapGradientLegend(legend) && ( + {GradientLegendRT.is(legend) && ( )} - {isInfraWaffleMapStepLegend(legend) && } + {StepLegendRT.is(legend) && } + {SteppedGradientLegendRT.is(legend) && ( + + )} ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx index 30447e524424..e5ee19e48884 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/legend_controls.tsx @@ -5,11 +5,10 @@ */ import { + EuiButtonEmpty, EuiButton, EuiButtonIcon, EuiFieldNumber, - EuiFlexGroup, - EuiFlexItem, EuiForm, EuiFormRow, EuiPopover, @@ -17,27 +16,65 @@ import { EuiSpacer, EuiSwitch, EuiSwitchEvent, - EuiText, + EuiSelect, + EuiRange, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { SyntheticEvent, useState } from 'react'; - +import React, { SyntheticEvent, useState, useCallback, useEffect } from 'react'; +import { first, last } from 'lodash'; import { euiStyled } from '../../../../../../../observability/public'; -import { InfraWaffleMapBounds } from '../../../../../lib/lib'; +import { InfraWaffleMapBounds, InventoryColorPalette, PALETTES } from '../../../../../lib/lib'; +import { WaffleLegendOptions } from '../../hooks/use_waffle_options'; +import { getColorPalette } from '../../lib/get_color_palette'; +import { convertBoundsToPercents } from '../../lib/convert_bounds_to_percents'; +import { SwatchLabel } from './swatch_label'; +import { PalettePreview } from './palette_preview'; interface Props { - onChange: (options: { auto: boolean; bounds: InfraWaffleMapBounds }) => void; + onChange: (options: { + auto: boolean; + bounds: InfraWaffleMapBounds; + legend: WaffleLegendOptions; + }) => void; bounds: InfraWaffleMapBounds; dataBounds: InfraWaffleMapBounds; autoBounds: boolean; boundsOverride: InfraWaffleMapBounds; + options: WaffleLegendOptions; } -export const LegendControls = ({ autoBounds, boundsOverride, onChange, dataBounds }: Props) => { +const PALETTE_NAMES: InventoryColorPalette[] = [ + 'temperature', + 'status', + 'cool', + 'warm', + 'positive', + 'negative', +]; + +const PALETTE_OPTIONS = PALETTE_NAMES.map(name => ({ text: PALETTES[name], value: name })); + +export const LegendControls = ({ + autoBounds, + boundsOverride, + onChange, + dataBounds, + options, +}: Props) => { const [isPopoverOpen, setPopoverState] = useState(false); const [draftAuto, setDraftAuto] = useState(autoBounds); - const [draftBounds, setDraftBounds] = useState(autoBounds ? dataBounds : boundsOverride); // should come from bounds prop + const [draftLegend, setLegendOptions] = useState(options); + const [draftBounds, setDraftBounds] = useState(convertBoundsToPercents(boundsOverride)); // should come from bounds prop + + useEffect(() => { + if (draftAuto) { + setDraftBounds(convertBoundsToPercents(dataBounds)); + } + }, [autoBounds, dataBounds, draftAuto, onChange, options]); + const buttonComponent = ( ); - const handleAutoChange = (e: EuiSwitchEvent) => { - setDraftAuto(e.target.checked); - }; + const handleAutoChange = useCallback( + (e: EuiSwitchEvent) => { + const auto = e.target.checked; + setDraftAuto(auto); + if (!auto) { + setDraftBounds(convertBoundsToPercents(boundsOverride)); + } + }, + [boundsOverride] + ); - const createBoundsHandler = (name: string) => (e: SyntheticEvent) => { - const value = parseFloat(e.currentTarget.value); - setDraftBounds({ ...draftBounds, [name]: value }); - }; + const handleReverseColors = useCallback( + (e: EuiSwitchEvent) => { + setLegendOptions(previous => ({ ...previous, reverseColors: e.target.checked })); + }, + [setLegendOptions] + ); - const handlePopoverClose = () => { + const handleMaxBounds = useCallback( + (e: SyntheticEvent) => { + const value = parseFloat(e.currentTarget.value); + // 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 handleMinBounds = useCallback( + (e: SyntheticEvent) => { + const value = parseFloat(e.currentTarget.value); + // 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 handleApplyClick = useCallback(() => { + onChange({ + auto: draftAuto, + bounds: { min: draftBounds.min / 100, max: draftBounds.max / 100 }, + legend: draftLegend, + }); + }, [onChange, draftAuto, draftBounds, draftLegend]); + + const handleCancelClick = useCallback(() => { + setDraftBounds(convertBoundsToPercents(boundsOverride)); + setDraftAuto(autoBounds); + setLegendOptions(options); setPopoverState(false); - }; + }, [autoBounds, boundsOverride, options]); - const handleApplyClick = () => { - onChange({ auto: draftAuto, bounds: draftBounds }); - }; + const handleStepsChange = useCallback( + e => { + setLegendOptions(previous => ({ ...previous, steps: parseInt(e.target.value, 10) })); + }, + [setLegendOptions] + ); + + const handlePaletteChange = useCallback( + e => { + setLegendOptions(previous => ({ ...previous, palette: e.target.value })); + }, + [setLegendOptions] + ); const commited = draftAuto === autoBounds && - boundsOverride.min === draftBounds.min && - boundsOverride.max === draftBounds.max; + boundsOverride.min * 100 === draftBounds.min && + boundsOverride.max * 100 === draftBounds.max && + options.steps === draftLegend.steps && + options.reverseColors === draftLegend.reverseColors && + options.palette === draftLegend.palette; const boundsValidRange = draftBounds.min < draftBounds.max; + const paletteColors = getColorPalette( + draftLegend.palette, + draftLegend.steps, + draftLegend.reverseColors + ); + const errors = !boundsValidRange + ? [ + i18n.translate('xpack.infra.legnedControls.boundRangeError', { + defaultMessage: 'Minimum must be smaller then the maximum', + }), + ] + : []; return ( Legend Options - - - + + <> + + + + + + + - - {(!boundsValidRange && ( - -

- -

-
- )) || - null} - - - + + + + + + + } + isInvalid={!boundsValidRange} + display="columnCompressed" + error={errors} + > +
+ - - - - - +
+
+ + } + isInvalid={!boundsValidRange} + error={errors} + > +
+ - +
+
+ + + + + -
+ + + + + + - - -
diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/palette_preview.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/palette_preview.tsx new file mode 100644 index 000000000000..cfcdad2d8927 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/palette_preview.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { euiStyled } from '../../../../../../../observability/public'; +import { InventoryColorPalette } from '../../../../../lib/lib'; +import { getColorPalette } from '../../lib/get_color_palette'; + +interface Props { + palette: InventoryColorPalette; + steps: number; + reverse: boolean; +} + +export const PalettePreview = ({ steps, palette, reverse }: Props) => { + const colors = getColorPalette(palette, steps, reverse); + return ( + + {colors.map(color => ( + + ))} + + ); +}; + +const Swatch = euiStyled.div` + width: 16px; + height: 12px; + flex: 0 0 auto; + &:first-child { + border-radius: ${props => props.theme.eui.euiBorderRadius} 0 0 ${props => + props.theme.eui.euiBorderRadius}; + } + &:last-child { + border-radius: 0 ${props => props.theme.eui.euiBorderRadius} ${props => + props.theme.eui.euiBorderRadius} 0; +`; + +const Swatches = euiStyled.div` + display: flex; +`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/stepped_gradient_legend.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/stepped_gradient_legend.tsx new file mode 100644 index 000000000000..2facef521c39 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/stepped_gradient_legend.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { euiStyled } from '../../../../../../../observability/public'; +import { + InfraWaffleMapBounds, + InfraFormatter, + InfraWaffleMapSteppedGradientLegend, +} from '../../../../../lib/lib'; + +interface Props { + legend: InfraWaffleMapSteppedGradientLegend; + bounds: InfraWaffleMapBounds; + formatter: InfraFormatter; +} + +export const SteppedGradientLegend: React.FC = ({ legend, bounds, formatter }) => { + return ( + + + + + + + + {legend.rules.map((rule, index) => ( + + ))} + + + ); +}; + +interface TickProps { + bounds: InfraWaffleMapBounds; + value: number; + formatter: InfraFormatter; +} + +const TickLabel = ({ value, bounds, formatter }: TickProps) => { + const normalizedValue = value === 0 ? bounds.min : bounds.max * value; + const style = { left: `${value * 100}%` }; + const label = formatter(normalizedValue); + return {label}; +}; + +const GradientStep = euiStyled.div` + height: ${props => props.theme.eui.paddingSizes.s}; + flex: 1 1 auto; + &:first-child { + border-radius: ${props => props.theme.eui.euiBorderRadius} 0 0 ${props => + props.theme.eui.euiBorderRadius}; + } + &:last-child { + border-radius: 0 ${props => props.theme.eui.euiBorderRadius} ${props => + props.theme.eui.euiBorderRadius} 0; + } +`; + +const Ticks = euiStyled.div` + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + top: -18px; +`; + +const Tick = euiStyled.div` + position: absolute; + font-size: 11px; + text-align: center; + top: 0; + left: 0; + white-space: nowrap; + transform: translate(-50%, 0); + &:first-child { + padding-left: 5px; + transform: translate(0, 0); + } + &:last-child { + padding-right: 5px; + transform: translate(-100%, 0); + } +`; + +const GradientContainer = euiStyled.div` + display: flex; + flex-direction; row; + align-items: stretch; + flex-grow: 1; +`; + +const LegendContainer = euiStyled.div` + position: absolute; + height: 10px; + bottom: 0; + left: 0; + right: 40px; +`; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/swatch_label.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/swatch_label.tsx new file mode 100644 index 000000000000..ae64188f8a46 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/swatch_label.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiColorPickerSwatch, EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React from 'react'; + +export interface Props { + color: string; + label: string; +} + +export const SwatchLabel = ({ label, color }: Props) => { + return ( + + + + + + + {label} + + + + ); +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts index 92f9ee4897f2..1e99e909cbb3 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_options.ts @@ -10,6 +10,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { constant, identity } from 'fp-ts/lib/function'; import createContainer from 'constate'; +import { InventoryColorPaletteRT } from '../../../../lib/lib'; import { SnapshotMetricInput, SnapshotGroupBy, @@ -32,6 +33,11 @@ export const DEFAULT_WAFFLE_OPTIONS_STATE: WaffleOptionsState = { accountId: '', region: '', customMetrics: [], + legend: { + palette: 'cool', + steps: 10, + reverseColors: false, + }, sort: { by: 'name', direction: 'desc' }, }; @@ -100,6 +106,13 @@ export const useWaffleOptions = () => { [setState] ); + const changeLegend = useCallback( + (legend: WaffleLegendOptions) => { + setState(previous => ({ ...previous, legend })); + }, + [setState] + ); + const changeSort = useCallback( (sort: WaffleSortOption) => { setState(previous => ({ ...previous, sort })); @@ -120,11 +133,20 @@ export const useWaffleOptions = () => { changeAccount, changeRegion, changeCustomMetrics, + changeLegend, changeSort, setWaffleOptionsState: setState, }; }; +const WaffleLegendOptionsRT = rt.type({ + palette: InventoryColorPaletteRT, + steps: rt.number, + reverseColors: rt.boolean, +}); + +export type WaffleLegendOptions = rt.TypeOf; + export const WaffleSortOptionRT = rt.type({ by: rt.keyof({ name: null, value: null }), direction: rt.keyof({ asc: null, desc: null }), @@ -149,6 +171,7 @@ export const WaffleOptionsStateRT = rt.type({ accountId: rt.string, region: rt.string, customMetrics: rt.array(SnapshotCustomMetricInputRT), + legend: WaffleLegendOptionsRT, sort: WaffleSortOptionRT, }); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts index 306c30228596..35313320a5dc 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts @@ -28,6 +28,7 @@ export const useWaffleViewState = () => { autoBounds, accountId, region, + legend, sort, setWaffleOptionsState, } = useWaffleOptionsContext(); @@ -49,6 +50,7 @@ export const useWaffleViewState = () => { time: currentTime, autoReload: isAutoReloading, filterQuery, + legend, }; const defaultViewState: WaffleViewState = { @@ -72,6 +74,7 @@ export const useWaffleViewState = () => { autoBounds: newState.autoBounds, accountId: newState.accountId, region: newState.region, + legend: newState.legend, }); if (newState.time) { setWaffleTimeState({ diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/color_from_value.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/color_from_value.ts index 334865306ee8..9cdd2032b73d 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/color_from_value.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/color_from_value.ts @@ -8,12 +8,14 @@ import { eq, first, gt, gte, last, lt, lte, sortBy } from 'lodash'; import { mix, parseToRgb, toColorString } from 'polished'; import { InfraWaffleMapBounds, - InfraWaffleMapGradientLegend, InfraWaffleMapLegend, InfraWaffleMapRuleOperator, - InfraWaffleMapStepLegend, + GradientLegendRT, + SteppedGradientLegendRT, + StepLegendRT, + InfraWaffleMapStepRule, + InfraWaffleMapGradientRule, } from '../../../../lib/lib'; -import { isInfraWaffleMapGradientLegend, isInfraWaffleMapStepLegend } from './type_guards'; const OPERATOR_TO_FN = { [InfraWaffleMapRuleOperator.eq]: eq, @@ -34,11 +36,16 @@ export const colorFromValue = ( defaultColor = 'rgba(217, 217, 217, 1)' ): string => { try { - if (isInfraWaffleMapStepLegend(legend)) { - return convertToRgbString(calculateStepColor(legend, value, defaultColor)); + if (StepLegendRT.is(legend)) { + return convertToRgbString(calculateStepColor(legend.rules, value, defaultColor)); } - if (isInfraWaffleMapGradientLegend(legend)) { - return convertToRgbString(calculateGradientColor(legend, value, bounds, defaultColor)); + if (GradientLegendRT.is(legend)) { + return convertToRgbString(calculateGradientColor(legend.rules, value, bounds, defaultColor)); + } + if (SteppedGradientLegendRT.is(legend)) { + return convertToRgbString( + calculateSteppedGradientColor(legend.rules, value, bounds, defaultColor) + ); } return defaultColor; } catch (error) { @@ -50,12 +57,37 @@ const normalizeValue = (min: number, max: number, value: number): number => { return (value - min) / (max - min); }; +export const calculateSteppedGradientColor = ( + rules: InfraWaffleMapGradientRule[], + value: number | string, + bounds: InfraWaffleMapBounds, + defaultColor = 'rgba(217, 217, 217, 1)' +) => { + const normalizedValue = normalizeValue(bounds.min, bounds.max, Number(value)); + const steps = rules.length; + + // Since the stepped legend matches a range we need to ensure anything outside + // the max bounds get's the maximum color. + if (gte(normalizedValue, last(rules).value)) { + return last(rules).color; + } + + return rules.reduce((color: string, rule) => { + const min = rule.value - 1 / steps; + const max = rule.value; + if (gte(normalizedValue, min) && lte(normalizedValue, max)) { + return rule.color; + } + return color; + }, first(rules).color || defaultColor); +}; + export const calculateStepColor = ( - legend: InfraWaffleMapStepLegend, + rules: InfraWaffleMapStepRule[], value: number | string, defaultColor = 'rgba(217, 217, 217, 1)' ): string => { - return sortBy(legend.rules, 'sortBy').reduce((color: string, rule) => { + return rules.reduce((color: string, rule) => { const operatorFn = OPERATOR_TO_FN[rule.operator]; if (operatorFn(value, rule.value)) { return rule.color; @@ -65,19 +97,19 @@ export const calculateStepColor = ( }; export const calculateGradientColor = ( - legend: InfraWaffleMapGradientLegend, + rules: InfraWaffleMapGradientRule[], value: number | string, bounds: InfraWaffleMapBounds, defaultColor = 'rgba(0, 179, 164, 1)' ): string => { - if (legend.rules.length === 0) { + if (rules.length === 0) { return defaultColor; } - if (legend.rules.length === 1) { - return last(legend.rules).color; + if (rules.length === 1) { + return last(rules).color; } const { min, max } = bounds; - const sortedRules = sortBy(legend.rules, 'value'); + const sortedRules = sortBy(rules, 'value'); const normValue = normalizeValue(min, max, Number(value)); const startRule = sortedRules.reduce((acc, rule) => { if (rule.value <= normValue) { diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/convert_bounds_to_percents.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/convert_bounds_to_percents.ts new file mode 100644 index 000000000000..3de3a438d269 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/convert_bounds_to_percents.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { InfraWaffleMapBounds } from '../../../../lib/lib'; +export const convertBoundsToPercents = (bounds: InfraWaffleMapBounds) => ({ + min: bounds.min * 100, + max: (bounds.max || 1) * 100, +}); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_legend.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_legend.ts new file mode 100644 index 000000000000..cdd9b7a97913 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_legend.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { InventoryColorPalette, InfraWaffleMapSteppedGradientLegend } from '../../../../lib/lib'; +import { getColorPalette } from './get_color_palette'; + +export const createLegend = ( + name: InventoryColorPalette, + steps: number = 10, + reverse: boolean = false +): InfraWaffleMapSteppedGradientLegend => { + const paletteColors = getColorPalette(name, steps, reverse); + return { + type: 'steppedGradient', + rules: paletteColors.map((color, index) => ({ + color, + value: (index + 1) / steps, + })), + }; +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/get_color_palette.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/get_color_palette.ts new file mode 100644 index 000000000000..66d257c179b8 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/get_color_palette.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + euiPaletteCool, + euiPaletteForStatus, + euiPaletteForTemperature, + euiPaletteNegative, + euiPalettePositive, + euiPaletteWarm, +} from '@elastic/eui'; +import { InventoryColorPalette } from '../../../../lib/lib'; + +const createColorPalette = (name: InventoryColorPalette = 'cool', steps: number = 10) => { + switch (name) { + case 'temperature': + return euiPaletteForTemperature(steps); + case 'status': + return euiPaletteForStatus(steps); + case 'warm': + return euiPaletteWarm(steps); + case 'positive': + return euiPalettePositive(steps); + case 'negative': + return euiPaletteNegative(steps); + default: + return euiPaletteCool(steps); + } +}; + +export const getColorPalette = ( + name: InventoryColorPalette = 'cool', + steps: number = 10, + reverse: boolean = false +) => { + return reverse ? createColorPalette(name, steps).reverse() : createColorPalette(name, steps); +}; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a3a134978582..95ef90cab04d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7641,7 +7641,6 @@ "xpack.infra.kibanaMetrics.nodeDoesNotExistErrorMessage": "{nodeId} が存在しません。", "xpack.infra.legendControls.applyButton": "適用", "xpack.infra.legendControls.buttonLabel": "凡例を校正", - "xpack.infra.legendControls.errorMessage": "最低値は最高値よりも低く設定する必要があります", "xpack.infra.legendControls.maxLabel": "最高", "xpack.infra.legendControls.minLabel": "最低", "xpack.infra.legendControls.switchLabel": "自動計算範囲", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 57e76768a2cf..1d1ce38f6a91 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7647,7 +7647,6 @@ "xpack.infra.kibanaMetrics.nodeDoesNotExistErrorMessage": "{nodeId} 不存在。", "xpack.infra.legendControls.applyButton": "应用", "xpack.infra.legendControls.buttonLabel": "配置图例", - "xpack.infra.legendControls.errorMessage": "最小值应小于最大值", "xpack.infra.legendControls.maxLabel": "最大值", "xpack.infra.legendControls.minLabel": "最小值", "xpack.infra.legendControls.switchLabel": "自动计算范围",