From 827f0c06fe356ccc35f022917321a3d65e9d7907 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Tue, 6 Oct 2020 20:51:23 +0200 Subject: [PATCH] [ML] Replace swim lane implementation with elastic-charts Heatmap (#79315) * [ML] replace swim lane vis * [ML] update swimlane_container, add colors constant * [ML] update swimlane_container, add colors constant * [ML] update swimlane_container, add colors constant * [ML] unfiltered label for Overall swim lane * [ML] tooltip content * [ML] fix styles, override legend styles * [ML] hide timeline for overall swimlane on the Anomaly Explorer page * [ML] remove explorer_swimlane component * [ML] remove dragselect dependency * [ML] fix types * [ML] fix tooltips, change mask fill to white * [ML] fix highlightedData * [ML] maxLegendHeight, fix Y-axis tooltip * [ML] clear selection * [ML] dataTestSubj * [ML] remove jest snapshot for explorer_swimlane * [ML] handle empty string label, fix translation key * [ML] better positioning for the loading indicator * [ML] update elastic/charts version * [ML] fix getFormattedSeverityScore and showSwimlane condition * [ML] fix selector for functional test * [ML] change the legend alignment * [ML] update elastic charts --- package.json | 2 +- packages/kbn-ui-shared-deps/package.json | 2 +- x-pack/package.json | 2 - .../plugins/ml/common/constants/anomalies.ts | 9 + x-pack/plugins/ml/common/index.ts | 2 +- .../plugins/ml/common/util/anomaly_utils.ts | 21 +- .../chart_tooltip/_chart_tooltip.scss | 1 - .../chart_tooltip/chart_tooltip.tsx | 94 ++- .../explorer_swimlane.test.tsx.snap | 3 - .../application/explorer/_explorer.scss | 291 +------ .../application/explorer/anomaly_timeline.tsx | 14 + .../explorer/explorer_swimlane.test.tsx | 126 --- .../explorer/explorer_swimlane.tsx | 758 ------------------ .../explorer/swimlane_container.tsx | 454 ++++++++--- .../embeddable_swim_lane_container.tsx | 1 + .../services/ml/anomaly_explorer.ts | 2 +- yarn.lock | 18 +- 17 files changed, 472 insertions(+), 1328 deletions(-) delete mode 100644 x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap delete mode 100644 x-pack/plugins/ml/public/application/explorer/explorer_swimlane.test.tsx delete mode 100644 x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx diff --git a/package.json b/package.json index 5137af553fff..9f9ad9ead709 100644 --- a/package.json +++ b/package.json @@ -230,7 +230,7 @@ "@babel/register": "^7.10.5", "@babel/types": "^7.11.0", "@elastic/apm-rum": "^5.6.1", - "@elastic/charts": "23.1.1", + "@elastic/charts": "23.2.1", "@elastic/ems-client": "7.10.0", "@elastic/eslint-config-kibana": "0.15.0", "@elastic/eslint-plugin-eui": "0.0.2", diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index 6f13e461cccb..e5ebb874e58a 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -9,7 +9,7 @@ "kbn:watch": "node scripts/build --dev --watch" }, "dependencies": { - "@elastic/charts": "23.1.1", + "@elastic/charts": "23.2.1", "@elastic/eui": "29.3.0", "@elastic/numeral": "^2.5.0", "@kbn/i18n": "1.0.0", diff --git a/x-pack/package.json b/x-pack/package.json index 1dc8b9aa7df5..941ebab2f3d6 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -73,7 +73,6 @@ "@types/d3-shape": "^1.3.1", "@types/d3-time": "^1.0.10", "@types/d3-time-format": "^2.1.1", - "@types/dragselect": "^1.13.1", "@types/elasticsearch": "^5.0.33", "@types/fancy-log": "^1.3.1", "@types/file-saver": "^2.0.0", @@ -165,7 +164,6 @@ "cypress-promise": "^1.1.0", "d3": "3.5.17", "d3-scale": "1.0.7", - "dragselect": "1.13.1", "enzyme": "^3.11.0", "enzyme-adapter-react-16": "^1.15.2", "enzyme-adapter-utils": "^1.13.0", diff --git a/x-pack/plugins/ml/common/constants/anomalies.ts b/x-pack/plugins/ml/common/constants/anomalies.ts index 73a24bc11fe6..2b3501554b8d 100644 --- a/x-pack/plugins/ml/common/constants/anomalies.ts +++ b/x-pack/plugins/ml/common/constants/anomalies.ts @@ -21,6 +21,15 @@ export enum ANOMALY_THRESHOLD { LOW = 0, } +export const SEVERITY_COLORS = { + CRITICAL: '#fe5050', + MAJOR: '#fba740', + MINOR: '#fdec25', + WARNING: '#8bc8fb', + LOW: '#d2e9f7', + BLANK: '#ffffff', +}; + export const PARTITION_FIELDS = ['partition_field', 'over_field', 'by_field'] as const; export const JOB_ID = 'job_id'; export const PARTITION_FIELD_VALUE = 'partition_field_value'; diff --git a/x-pack/plugins/ml/common/index.ts b/x-pack/plugins/ml/common/index.ts index d808e4277f07..d527a9a9780a 100644 --- a/x-pack/plugins/ml/common/index.ts +++ b/x-pack/plugins/ml/common/index.ts @@ -5,6 +5,6 @@ */ export { SearchResponse7 } from './types/es_client'; -export { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from './constants/anomalies'; +export { ANOMALY_SEVERITY, ANOMALY_THRESHOLD, SEVERITY_COLORS } from './constants/anomalies'; export { getSeverityColor, getSeverityType } from './util/anomaly_utils'; export { composeValidators, patternValidator } from './util/validators'; diff --git a/x-pack/plugins/ml/common/util/anomaly_utils.ts b/x-pack/plugins/ml/common/util/anomaly_utils.ts index 16802040059a..28b2f50ae269 100644 --- a/x-pack/plugins/ml/common/util/anomaly_utils.ts +++ b/x-pack/plugins/ml/common/util/anomaly_utils.ts @@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n'; import { CONDITIONS_NOT_SUPPORTED_FUNCTIONS } from '../constants/detector_rule'; import { MULTI_BUCKET_IMPACT } from '../constants/multi_bucket_impact'; -import { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from '../constants/anomalies'; +import { ANOMALY_SEVERITY, ANOMALY_THRESHOLD, SEVERITY_COLORS } from '../constants/anomalies'; import { AnomalyRecordDoc } from '../types/anomalies'; export interface SeverityType { @@ -109,6 +109,13 @@ function getSeverityTypes() { }); } +/** + * Return formatted severity score. + */ +export function getFormattedSeverityScore(score: number): string { + return score < 1 ? '< 1' : String(parseInt(String(score), 10)); +} + // Returns a severity label (one of critical, major, minor, warning or unknown) // for the supplied normalized anomaly score (a value between 0 and 100). export function getSeverity(normalizedScore: number): SeverityType { @@ -168,17 +175,17 @@ export function getSeverityWithLow(normalizedScore: number): SeverityType { // for the supplied normalized anomaly score (a value between 0 and 100). export function getSeverityColor(normalizedScore: number): string { if (normalizedScore >= ANOMALY_THRESHOLD.CRITICAL) { - return '#fe5050'; + return SEVERITY_COLORS.CRITICAL; } else if (normalizedScore >= ANOMALY_THRESHOLD.MAJOR) { - return '#fba740'; + return SEVERITY_COLORS.MAJOR; } else if (normalizedScore >= ANOMALY_THRESHOLD.MINOR) { - return '#fdec25'; + return SEVERITY_COLORS.MINOR; } else if (normalizedScore >= ANOMALY_THRESHOLD.WARNING) { - return '#8bc8fb'; + return SEVERITY_COLORS.WARNING; } else if (normalizedScore >= ANOMALY_THRESHOLD.LOW) { - return '#d2e9f7'; + return SEVERITY_COLORS.LOW; } else { - return '#ffffff'; + return SEVERITY_COLORS.BLANK; } } diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/_chart_tooltip.scss b/x-pack/plugins/ml/public/application/components/chart_tooltip/_chart_tooltip.scss index 46e5d91e1cc8..25be39f3ea2d 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/_chart_tooltip.scss +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/_chart_tooltip.scss @@ -1,7 +1,6 @@ .mlChartTooltip { @include euiToolTipStyle('s'); @include euiFontSizeXS; - position: absolute; padding: 0; transition: opacity $euiAnimSpeedNormal; pointer-events: none; diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx index 0d94c5ccdfe0..d0ecf65bca44 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx @@ -23,6 +23,57 @@ const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFo return formatter ? formatter(headerData) : headerData.label; }; +/** + * Pure component for rendering the tooltip content with a custom layout across the ML plugin. + */ +export const FormattedTooltip: FC<{ tooltipData: TooltipData }> = ({ tooltipData }) => { + return ( +
+ {tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && ( +
{renderHeader(tooltipData[0])}
+ )} + {tooltipData.length > 1 && ( +
+ {tooltipData + .slice(1) + .map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => { + const classes = classNames('mlChartTooltip__item', { + // eslint-disable-next-line @typescript-eslint/naming-convention + echTooltip__rowHighlighted: isHighlighted, + }); + + const renderValue = Array.isArray(value) + ? value.map((v) =>
{v}
) + : value; + + return ( +
+ + + {label} + + + {renderValue} + + +
+ ); + })} +
+ )} +
+ ); +}; + +/** + * Tooltip component bundled with the {@link ChartTooltipService} + */ const Tooltip: FC<{ service: ChartTooltipService }> = React.memo(({ service }) => { const [tooltipData, setData] = useState([]); const refCallback = useRef(); @@ -57,50 +108,9 @@ const Tooltip: FC<{ service: ChartTooltipService }> = React.memo(({ service }) =
- {tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && ( -
{renderHeader(tooltipData[0])}
- )} - {tooltipData.length > 1 && ( -
- {tooltipData - .slice(1) - .map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => { - const classes = classNames('mlChartTooltip__item', { - // eslint-disable-next-line @typescript-eslint/naming-convention - echTooltip__rowHighlighted: isHighlighted, - }); - - const renderValue = Array.isArray(value) - ? value.map((v) =>
{v}
) - : value; - - return ( -
- - - {label} - - - {renderValue} - - -
- ); - })} -
- )} +
); }) as TooltipTriggerProps['tooltip'], diff --git a/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap b/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap deleted file mode 100644 index 4adaac1319d5..000000000000 --- a/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ExplorerSwimlane Overall swimlane 1`] = `"
Overall
2017-02-07T00:00:00Z2017-02-07T00:30:00Z2017-02-07T01:00:00Z2017-02-07T01:30:00Z2017-02-07T02:00:00Z2017-02-07T02:30:00Z2017-02-07T03:00:00Z2017-02-07T03:30:00Z2017-02-07T04:00:00Z2017-02-07T04:30:00Z2017-02-07T05:00:00Z2017-02-07T05:30:00Z2017-02-07T06:00:00Z2017-02-07T06:30:00Z2017-02-07T07:00:00Z2017-02-07T07:30:00Z2017-02-07T08:00:00Z2017-02-07T08:30:00Z2017-02-07T09:00:00Z2017-02-07T09:30:00Z2017-02-07T10:00:00Z2017-02-07T10:30:00Z2017-02-07T11:00:00Z2017-02-07T11:30:00Z2017-02-07T12:00:00Z2017-02-07T12:30:00Z2017-02-07T13:00:00Z2017-02-07T13:30:00Z2017-02-07T14:00:00Z2017-02-07T14:30:00Z2017-02-07T15:00:00Z2017-02-07T15:30:00Z2017-02-07T16:00:00Z
"`; diff --git a/x-pack/plugins/ml/public/application/explorer/_explorer.scss b/x-pack/plugins/ml/public/application/explorer/_explorer.scss index 63c471e66c49..d16a84a23c81 100644 --- a/x-pack/plugins/ml/public/application/explorer/_explorer.scss +++ b/x-pack/plugins/ml/public/application/explorer/_explorer.scss @@ -1,48 +1,10 @@ $borderRadius: $euiBorderRadius / 2; -.ml-swimlane-selector { - visibility: hidden; -} - .ml-explorer { width: 100%; display: inline-block; color: $euiColorDarkShade; - .visError { - h4 { - margin-top: 50px; - } - } - - .no-results-container { - text-align: center; - font-size: $euiFontSizeL; - - // SASSTODO: Use a proper calc - padding-top: 60px; - - .no-results { - background-color: $euiFocusBackgroundColor; - padding: $euiSize; - border-radius: $euiBorderRadius; - display: inline-block; - - // SASSTODO: Make a proper selector - i { - color: $euiColorPrimary; - margin-right: $euiSizeXS; - } - - - // SASSTODO: Make a proper selector - div:nth-child(2) { - margin-top: $euiSizeXS; - font-size: $euiFontSizeXS; - } - } - } - .mlAnomalyExplorer__filterBar { padding-right: $euiSize; padding-left: $euiSize; @@ -79,23 +41,6 @@ $borderRadius: $euiBorderRadius / 2; } } - .ml-controls { - padding-bottom: $euiSizeS; - - // SASSTODO: Make a proper selector - label { - font-size: $euiFontSizeXS; - padding: $euiSizeXS; - padding-top: 0; - } - - .kuiButtonGroup { - padding: 0px $euiSizeXS 0px 0px; - position: relative; - display: inline-block; - } - } - .ml-anomalies-controls { padding-top: $euiSizeXS; @@ -103,235 +48,19 @@ $borderRadius: $euiBorderRadius / 2; padding-top: $euiSizeL; } } - - // SASSTODO: This entire selector needs to be rewritten. - // It looks extremely brittle with very specific sizing units - .mlExplorerSwimlane { - user-select: none; - padding: 0; - - line.gridLine { - stroke: $euiBorderColor; - fill: none; - shape-rendering: crispEdges; - stroke-width: 1px; - } - - rect.gridCell { - shape-rendering: crispEdges; - } - - rect.hovered { - stroke: $euiColorDarkShade; - stroke-width: 2px; - } - - text.laneLabel { - font-size: 9pt; - font-family: $euiFontFamily; - fill: $euiColorDarkShade; - } - - text.timeLabel { - font-size: 8pt; - font-family: $euiFontFamily; - fill: $euiColorDarkShade; - } - } } -/* using !important in the following rule because other related legacy rules have more specifity. */ -.mlDragselectDragging { - - .sl-cell-inner, - .sl-cell-inner-dragselect { - opacity: 0.6 !important; +.mlSwimLaneContainer { + /* Override legend styles */ + .echLegendListContainer { + height: 34px !important; } -} - -/* using !important in the following rule because other related legacy rules have more specifity. */ -.mlHideRangeSelection { - div.ml-swimlanes { - div.lane { - div.cells-container { - .sl-cell.ds-selected { - - .sl-cell-inner, - .sl-cell-inner-dragselect { - border-width: 0px !important; - opacity: 1 !important; - } - - .sl-cell-inner.sl-cell-inner-selected { - border-width: $euiSizeXS / 2 !important; - } - - .sl-cell-inner.sl-cell-inner-masked { - opacity: 0.6 !important; - } - } - } - } - } -} - -.ml-swimlanes { - margin: 0px 0px 0px 10px; - div.cells-marker-container { - margin-left: 176px; - height: 22px; - white-space: nowrap; - - // background-color: #CCC; - .sl-cell { - height: 10px; - display: inline-block; - vertical-align: top; - margin-top: 16px; - text-align: center; - visibility: hidden; - cursor: default; - - i { - color: $euiColorDarkShade; - } - } - - .sl-cell-hover { - visibility: visible; - - i { - display: block; - margin-top: -6px; - } - } - - .sl-cell-active-hover { - visibility: visible; - - .floating-time-label { - display: inline-block; - } - } - } - - div.lane { - height: 30px; - border-bottom: 0px; - border-radius: $borderRadius; - white-space: nowrap; - - &:not(:first-child) { - margin-top: -1px; - } - - div.lane-label { - display: inline-block; - font-size: $euiFontSizeXS; - height: 30px; - text-align: right; - vertical-align: middle; - border-radius: $borderRadius; - padding-right: 5px; - margin-right: 5px; - border: 1px solid transparent; - overflow: hidden; - text-overflow: ellipsis; - } - - div.lane-label.lane-label-masked { - opacity: 0.3; - } - - div.cells-container { - border: $euiBorderThin; - border-right: 0px; - display: inline-block; - height: 30px; - vertical-align: middle; - background-color: $euiColorEmptyShade; - - .sl-cell { - color: $euiColorEmptyShade; - cursor: default; - display: inline-block; - height: 29px; - border-right: $euiBorderThin; - vertical-align: top; - position: relative; - - .sl-cell-inner, - .sl-cell-inner-dragselect { - height: 26px; - margin: 1px; - border-radius: $borderRadius; - text-align: center; - } - - .sl-cell-inner.sl-cell-inner-masked { - opacity: 0.2; - } - - .sl-cell-inner.sl-cell-inner-selected, - .sl-cell-inner-dragselect.sl-cell-inner-selected { - border: 2px solid $euiColorDarkShade; - } - - .sl-cell-inner.sl-cell-inner-selected.sl-cell-inner-masked, - .sl-cell-inner-dragselect.sl-cell-inner-selected.sl-cell-inner-masked { - border: 2px solid $euiColorFullShade; - opacity: 0.4; - } - } - - .sl-cell:hover { - .sl-cell-inner { - opacity: 0.8; - cursor: pointer; - } - } - - .sl-cell.ds-selected { - - .sl-cell-inner, - .sl-cell-inner-dragselect { - border: 2px solid $euiColorDarkShade; - border-radius: $borderRadius; - opacity: 1; - } - } - - } - } - - div.lane:last-child { - div.cells-container { - .sl-cell { - border-bottom: $euiBorderThin; - } - } - } - - .time-tick-labels { - height: 25px; - margin-top: $euiSizeXS / 2; - margin-left: 175px; - - /* hide d3's domain line */ - path.domain { - display: none; - } - - /* hide d3's tick line */ - g.tick line { - display: none; - } - - /* override d3's default tick styles */ - g.tick text { - font-size: 11px; - fill: $euiColorMediumShade; - } + .echLegendList { + display: flex !important; + justify-content: space-between !important; + flex-wrap: nowrap; + position: absolute; + right: 0; } } diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx index 45dada84de20..76f678554413 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx @@ -18,6 +18,7 @@ import { EuiTitle, EuiSpacer, EuiContextMenuItem, + EuiButtonEmpty, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -156,6 +157,16 @@ export const AnomalyTimeline: FC = React.memo( /> + {selectedCells ? ( + + + + + + ) : null}
{viewByLoadedForTimeFormatted && ( @@ -211,6 +222,7 @@ export const AnomalyTimeline: FC = React.memo( = React.memo( onResize={explorerService.setSwimlaneContainerWidth} isLoading={loading} noDataWarning={} + showTimeline={false} /> {viewBySwimlaneOptions.length > 0 && ( { - const original = jest.requireActual('d3'); - - return { - ...original, - transform: jest.fn().mockReturnValue({ - translate: jest.fn().mockReturnValue(0), - }), - }; -}); - -jest.mock('@elastic/eui', () => { - return { - htmlIdGenerator: jest.fn(() => { - return jest.fn(() => { - return 'test-gen-id'; - }); - }), - }; -}); - -function getExplorerSwimlaneMocks() { - const swimlaneData = ({ laneLabels: [] } as unknown) as OverallSwimlaneData; - - const timeBuckets = ({ - setInterval: jest.fn(), - getScaledDateFormat: jest.fn(), - } as unknown) as InstanceType; - - const tooltipService = ({ - show: jest.fn(), - hide: jest.fn(), - } as unknown) as ChartTooltipService; - - return { - timeBuckets, - swimlaneData, - tooltipService, - parentRef: {} as React.RefObject, - }; -} - -const mockChartWidth = 800; - -describe('ExplorerSwimlane', () => { - const mockedGetBBox = { x: 0, y: -11.5, width: 12.1875, height: 14.5 } as DOMRect; - // @ts-ignore - const originalGetBBox = SVGElement.prototype.getBBox; - beforeEach(() => { - moment.tz.setDefault('UTC'); - // @ts-ignore - SVGElement.prototype.getBBox = () => mockedGetBBox; - }); - afterEach(() => { - moment.tz.setDefault('Browser'); - // @ts-ignore - SVGElement.prototype.getBBox = originalGetBBox; - }); - - test('Minimal initialization', () => { - const mocks = getExplorerSwimlaneMocks(); - - const wrapper = mountWithIntl( - - ); - - expect(wrapper.html()).toBe( - '
' - ); - - // test calls to mock functions - // @ts-ignore - expect(mocks.timeBuckets.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1); - // @ts-ignore - expect(mocks.timeBuckets.getScaledDateFormat.mock.calls.length).toBeGreaterThanOrEqual(1); - }); - - test('Overall swimlane', () => { - const mocks = getExplorerSwimlaneMocks(); - - const wrapper = mountWithIntl( - - ); - - expect(wrapper.html()).toMatchSnapshot(); - - // test calls to mock functions - // @ts-ignore - expect(mocks.timeBuckets.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1); - // @ts-ignore - expect(mocks.timeBuckets.getScaledDateFormat.mock.calls.length).toBeGreaterThanOrEqual(1); - }); -}); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx deleted file mode 100644 index 569709d648b3..000000000000 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx +++ /dev/null @@ -1,758 +0,0 @@ -/* - * 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. - */ - -/* - * React component for rendering Explorer dashboard swimlanes. - */ - -import React from 'react'; -import './_explorer.scss'; -import { isEqual, uniq, get } from 'lodash'; -import d3 from 'd3'; -import moment from 'moment'; -import DragSelect from 'dragselect'; - -import { i18n } from '@kbn/i18n'; -import { Subject, Subscription } from 'rxjs'; -import { TooltipValue } from '@elastic/charts'; -import { htmlIdGenerator } from '@elastic/eui'; -import { formatHumanReadableDateTime } from '../../../common/util/date_utils'; -import { numTicksForDateFormat } from '../util/chart_utils'; -import { getSeverityColor } from '../../../common/util/anomaly_utils'; -import { mlEscape } from '../util/string_utils'; -import { ALLOW_CELL_RANGE_SELECTION } from './explorer_dashboard_service'; -import { DRAG_SELECT_ACTION, SwimlaneType } from './explorer_constants'; -import { EMPTY_FIELD_VALUE_LABEL } from '../timeseriesexplorer/components/entity_control/entity_control'; -import { TimeBuckets as TimeBucketsClass } from '../util/time_buckets'; -import { - ChartTooltipService, - ChartTooltipValue, -} from '../components/chart_tooltip/chart_tooltip_service'; -import { AppStateSelectedCells, OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils'; - -const SCSS = { - mlDragselectDragging: 'mlDragselectDragging', - mlHideRangeSelection: 'mlHideRangeSelection', -}; - -interface NodeWithData extends Node { - __clickData__: { - time: number; - bucketScore: number; - laneLabel: string; - swimlaneType: string; - }; -} - -interface SelectedData { - bucketScore: number; - laneLabels: string[]; - times: number[]; -} - -export interface ExplorerSwimlaneProps { - chartWidth: number; - filterActive?: boolean; - maskAll?: boolean; - timeBuckets: InstanceType; - swimlaneData: OverallSwimlaneData | ViewBySwimLaneData; - swimlaneType: SwimlaneType; - selection?: AppStateSelectedCells; - onCellsSelection: (payload?: AppStateSelectedCells) => void; - tooltipService: ChartTooltipService; - 'data-test-subj'?: string; - /** - * We need to be aware of the parent element in order to set - * the height so the swim lane widget doesn't jump during loading - * or page changes. - */ - parentRef: React.RefObject; -} - -export class ExplorerSwimlane extends React.Component { - // Since this component is mostly rendered using d3 and cellMouseoverActive is only - // relevant for d3 based interaction, we don't manage this using React's state - // and intentionally circumvent the component lifecycle when updating it. - cellMouseoverActive = true; - - selection: AppStateSelectedCells | undefined = undefined; - - dragSelectSubscriber: Subscription | null = null; - - rootNode = React.createRef(); - - isSwimlaneSelectActive = false; - // make sure dragSelect is only available if the mouse pointer is actually over a swimlane - disableDragSelectOnMouseLeave = true; - - dragSelect$ = new Subject<{ - action: typeof DRAG_SELECT_ACTION[keyof typeof DRAG_SELECT_ACTION]; - elements?: any[]; - }>(); - - /** - * Unique id for swim lane instance - */ - rootNodeId = htmlIdGenerator()(); - - /** - * Initialize drag select instance - */ - dragSelect = new DragSelect({ - selectorClass: 'ml-swimlane-selector', - selectables: document.querySelectorAll(`#${this.rootNodeId} .sl-cell`), - callback: (elements) => { - if (elements.length > 1 && !ALLOW_CELL_RANGE_SELECTION) { - elements = [elements[0]]; - } - - if (elements.length > 0) { - this.dragSelect$.next({ - action: DRAG_SELECT_ACTION.NEW_SELECTION, - elements, - }); - } - - this.disableDragSelectOnMouseLeave = true; - }, - onDragStart: (e) => { - // make sure we don't trigger text selection on label - e.preventDefault(); - // clear previous selection - this.clearSelection(); - let target = e.target as HTMLElement; - while (target && target !== document.body && !target.classList.contains('sl-cell')) { - target = target.parentNode as HTMLElement; - } - if (ALLOW_CELL_RANGE_SELECTION && target !== document.body) { - this.dragSelect$.next({ - action: DRAG_SELECT_ACTION.DRAG_START, - }); - this.disableDragSelectOnMouseLeave = false; - } - }, - onElementSelect: () => { - if (ALLOW_CELL_RANGE_SELECTION) { - this.dragSelect$.next({ - action: DRAG_SELECT_ACTION.ELEMENT_SELECT, - }); - } - }, - }); - - componentDidMount() { - // property for data comparison to be able to filter - // consecutive click events with the same data. - let previousSelectedData: any = null; - - // Listen for dragSelect events - this.dragSelectSubscriber = this.dragSelect$.subscribe(({ action, elements = [] }) => { - const element = d3.select(this.rootNode.current!.parentNode!); - const { swimlaneType } = this.props; - - if (action === DRAG_SELECT_ACTION.NEW_SELECTION && elements.length > 0) { - element.classed(SCSS.mlDragselectDragging, false); - const firstSelectedCell = (d3.select(elements[0]).node() as NodeWithData).__clickData__; - - if ( - typeof firstSelectedCell !== 'undefined' && - swimlaneType === firstSelectedCell.swimlaneType - ) { - const selectedData: SelectedData = elements.reduce( - (d, e) => { - const cell = (d3.select(e).node() as NodeWithData).__clickData__; - d.bucketScore = Math.max(d.bucketScore, cell.bucketScore); - d.laneLabels.push(cell.laneLabel); - d.times.push(cell.time); - return d; - }, - { - bucketScore: 0, - laneLabels: [], - times: [], - } - ); - - selectedData.laneLabels = uniq(selectedData.laneLabels); - selectedData.times = uniq(selectedData.times); - if (isEqual(selectedData, previousSelectedData) === false) { - // If no cells containing anomalies have been selected, - // immediately clear the selection, otherwise trigger - // a reload with the updated selected cells. - if (selectedData.bucketScore === 0) { - elements.map((e) => d3.select(e).classed('ds-selected', false)); - this.selectCell([], selectedData); - previousSelectedData = null; - } else { - this.selectCell(elements, selectedData); - previousSelectedData = selectedData; - } - } - } - - this.cellMouseoverActive = true; - } else if (action === DRAG_SELECT_ACTION.ELEMENT_SELECT) { - element.classed(SCSS.mlDragselectDragging, true); - } else if (action === DRAG_SELECT_ACTION.DRAG_START) { - previousSelectedData = null; - this.cellMouseoverActive = false; - this.props.tooltipService.hide(); - } - }); - - this.renderSwimlane(); - - this.dragSelect.stop(); - } - - componentDidUpdate() { - this.renderSwimlane(); - } - - componentWillUnmount() { - this.dragSelectSubscriber!.unsubscribe(); - // Remove selector element from DOM - this.dragSelect.selector.remove(); - // removes all mousedown event handlers - this.dragSelect.stop(true); - } - - selectCell(cellsToSelect: any[], { laneLabels, bucketScore, times }: SelectedData) { - const { selection, swimlaneData, swimlaneType } = this.props; - - let triggerNewSelection = false; - - if (cellsToSelect.length > 1 || bucketScore > 0) { - triggerNewSelection = true; - } - - // Check if the same cells were selected again, if so clear the selection, - // otherwise activate the new selection. The two objects are built for - // comparison because we cannot simply compare to "appState.mlExplorerSwimlane" - // since it also includes the "viewBy" attribute which might differ depending - // on whether the overall or viewby swimlane was selected. - const oldSelection = { - selectedType: selection && selection.type, - selectedLanes: selection && selection.lanes, - selectedTimes: selection && selection.times, - }; - - const newSelection = { - selectedType: swimlaneType, - selectedLanes: laneLabels, - selectedTimes: d3.extent(times), - }; - - if (isEqual(oldSelection, newSelection)) { - triggerNewSelection = false; - } - - if (triggerNewSelection === false) { - this.swimLaneSelectionCompleted(); - return; - } - - const selectedCells = { - viewByFieldName: swimlaneData.fieldName, - lanes: laneLabels, - times: d3.extent(times), - type: swimlaneType, - }; - this.swimLaneSelectionCompleted(selectedCells); - } - - /** - * Highlights DOM elements of the swim lane cells - */ - highlightSwimLaneCells(selection: AppStateSelectedCells | undefined) { - const element = d3.select(this.rootNode.current!.parentNode!); - - const { swimlaneType, swimlaneData, filterActive, maskAll } = this.props; - - const { laneLabels: lanes, earliest: startTime, latest: endTime } = swimlaneData; - - // Check for selection and reselect the corresponding swimlane cell - // if the time range and lane label are still in view. - const selectionState = selection; - const selectedType = get(selectionState, 'type', undefined); - const selectionViewByFieldName = get(selectionState, 'viewByFieldName', ''); - - // If a selection was done in the other swimlane, add the "masked" classes - // to de-emphasize the swimlane cells. - if (swimlaneType !== selectedType && selectedType !== undefined) { - element.selectAll('.lane-label').classed('lane-label-masked', true); - element.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', true); - } - - const cellsToSelect: Node[] = []; - const selectedLanes = get(selectionState, 'lanes', []); - const selectedTimes = get(selectionState, 'times', []); - const selectedTimeExtent = d3.extent(selectedTimes); - - if ( - (swimlaneType !== selectedType || - (swimlaneData.fieldName !== undefined && - swimlaneData.fieldName !== selectionViewByFieldName)) && - filterActive === false - ) { - // Not this swimlane which was selected. - return; - } - - selectedLanes.forEach((selectedLane) => { - if ( - lanes.indexOf(selectedLane) > -1 && - selectedTimeExtent[0] >= startTime && - selectedTimeExtent[1] <= endTime - ) { - // Locate matching cell - look for exact time, otherwise closest before. - const laneCells = element.selectAll(`div[data-lane-label="${mlEscape(selectedLane)}"]`); - - laneCells.each(function (this: HTMLElement) { - const cell = d3.select(this); - const cellTime = parseInt(cell.attr('data-time'), 10); - if (cellTime >= selectedTimeExtent[0] && cellTime <= selectedTimeExtent[1]) { - cellsToSelect.push(cell.node()); - } - }); - } - }); - - const selectedMaxBucketScore = cellsToSelect.reduce((maxBucketScore, cell) => { - return Math.max(maxBucketScore, +d3.select(cell).attr('data-bucket-score') || 0); - }, 0); - - const selectedCellTimes = cellsToSelect.map((e) => { - return (d3.select(e).node() as NodeWithData).__clickData__.time; - }); - - if (cellsToSelect.length > 1 || selectedMaxBucketScore > 0) { - this.highlightSelection(cellsToSelect, selectedLanes, selectedCellTimes); - } else if (filterActive === true) { - this.maskIrrelevantSwimlanes(Boolean(maskAll)); - } else { - this.clearSelection(); - } - - // cache selection to prevent rerenders - this.selection = selection; - } - - highlightSelection(cellsToSelect: Node[], laneLabels: string[], times: number[]) { - // This selects the embeddable container - const wrapper = d3.select(`#${this.rootNodeId}`); - - wrapper.selectAll('.lane-label').classed('lane-label-masked', true); - wrapper - .selectAll('.sl-cell-inner,.sl-cell-inner-dragselect') - .classed('sl-cell-inner-masked', true); - wrapper - .selectAll( - '.sl-cell-inner.sl-cell-inner-selected,.sl-cell-inner-dragselect.sl-cell-inner-selected' - ) - .classed('sl-cell-inner-selected', false); - - d3.selectAll(cellsToSelect) - .selectAll('.sl-cell-inner,.sl-cell-inner-dragselect') - .classed('sl-cell-inner-masked', false) - .classed('sl-cell-inner-selected', true); - - const rootParent = d3.select(this.rootNode.current!.parentNode!); - rootParent.selectAll('.lane-label').classed('lane-label-masked', function (this: HTMLElement) { - return laneLabels.indexOf(d3.select(this).text()) === -1; - }); - } - - /** - * TODO should happen with props instead of imperative check - * @param maskAll - */ - maskIrrelevantSwimlanes(maskAll: boolean) { - if (maskAll === true) { - // This selects both overall and viewby swimlane - const allSwimlanes = d3.selectAll('.mlExplorerSwimlane'); - allSwimlanes.selectAll('.lane-label').classed('lane-label-masked', true); - allSwimlanes - .selectAll('.sl-cell-inner,.sl-cell-inner-dragselect') - .classed('sl-cell-inner-masked', true); - } else { - const overallSwimlane = d3.select('.ml-swimlane-overall'); - overallSwimlane.selectAll('.lane-label').classed('lane-label-masked', true); - overallSwimlane - .selectAll('.sl-cell-inner,.sl-cell-inner-dragselect') - .classed('sl-cell-inner-masked', true); - } - } - - clearSelection() { - // This selects both overall and viewby swimlane - const wrapper = d3.selectAll('.mlExplorerSwimlane'); - - wrapper.selectAll('.lane-label').classed('lane-label-masked', false); - wrapper.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', false); - wrapper - .selectAll('.sl-cell-inner.sl-cell-inner-selected') - .classed('sl-cell-inner-selected', false); - wrapper - .selectAll('.sl-cell-inner-dragselect.sl-cell-inner-selected') - .classed('sl-cell-inner-selected', false); - wrapper.selectAll('.ds-selected').classed('sl-cell-inner-selected', false); - } - - renderSwimlane() { - const element = d3.select(this.rootNode.current!.parentNode!); - - // Consider the setting to support to select a range of cells - if (!ALLOW_CELL_RANGE_SELECTION) { - element.classed(SCSS.mlHideRangeSelection, true); - } - - // This getter allows us to fetch the current value in `cellMouseover()`. - // Otherwise it will just refer to the value when `cellMouseover()` was instantiated. - const getCellMouseoverActive = () => this.cellMouseoverActive; - - const { - chartWidth, - filterActive, - timeBuckets, - swimlaneData, - swimlaneType, - selection, - } = this.props; - - const { - laneLabels: lanes, - earliest: startTime, - latest: endTime, - interval: stepSecs, - points, - } = swimlaneData; - - const cellMouseover = ( - target: HTMLElement, - laneLabel: string, - bucketScore: number, - index: number, - time: number - ) => { - if (bucketScore === undefined || getCellMouseoverActive() === false) { - return; - } - - const displayScore = bucketScore > 1 ? parseInt(String(bucketScore), 10) : '< 1'; - - // Display date using same format as Kibana visualizations. - const formattedDate = formatHumanReadableDateTime(time * 1000); - const tooltipData: TooltipValue[] = [{ label: formattedDate } as TooltipValue]; - - if (swimlaneData.fieldName !== undefined) { - tooltipData.push({ - label: swimlaneData.fieldName, - value: laneLabel, - // @ts-ignore - seriesIdentifier: { - key: laneLabel, - }, - valueAccessor: 'fieldName', - }); - } - tooltipData.push({ - label: i18n.translate('xpack.ml.explorer.swimlane.maxAnomalyScoreLabel', { - defaultMessage: 'Max anomaly score', - }), - value: displayScore, - color: colorScore(bucketScore), - // @ts-ignore - seriesIdentifier: { - key: laneLabel, - }, - valueAccessor: 'anomaly_score', - }); - - const offsets = target.className === 'sl-cell-inner' ? { x: 6, y: 0 } : { x: 8, y: 1 }; - - this.props.tooltipService.show(tooltipData, target, { - x: target.offsetWidth + offsets.x, - y: 6 + offsets.y, - }); - }; - - function colorScore(value: number): string { - return getSeverityColor(value); - } - - const numBuckets = Math.round((endTime - startTime) / stepSecs); - const cellHeight = 30; - const height = (lanes.length + 1) * cellHeight - 10; - // Set height for the wrapper element - if (this.props.parentRef.current) { - this.props.parentRef.current.style.height = `${height + 20}px`; - } - - const laneLabelWidth = 170; - const swimlanes = element.select('.ml-swimlanes'); - swimlanes.html(''); - - const cellWidth = Math.floor((chartWidth / numBuckets) * 100) / 100; - - const xAxisWidth = cellWidth * numBuckets; - const xAxisScale = d3.time - .scale() - .domain([new Date(startTime * 1000), new Date(endTime * 1000)]) - .range([0, xAxisWidth]); - - // Get the scaled date format to use for x axis tick labels. - timeBuckets.setInterval(`${stepSecs}s`); - const xAxisTickFormat = timeBuckets.getScaledDateFormat(); - - function cellMouseOverFactory(time: number, i: number) { - // Don't use an arrow function here because we need access to `this`, - // which is where d3 supplies a reference to the corresponding DOM element. - return function (this: HTMLElement, lane: string) { - const bucketScore = getBucketScore(lane, time); - if (bucketScore !== 0) { - lane = lane === '' ? EMPTY_FIELD_VALUE_LABEL : lane; - cellMouseover(this, lane, bucketScore, i, time); - } - }; - } - - const cellMouseleave = () => { - this.props.tooltipService.hide(); - }; - - const d3Lanes = swimlanes.selectAll('.lane').data(lanes); - const d3LanesEnter = d3Lanes.enter().append('div').classed('lane', true); - - const that = this; - - d3LanesEnter - .append('div') - .classed('lane-label', true) - .style('width', `${laneLabelWidth}px`) - .html((label: string) => { - const showFilterContext = filterActive === true && label === 'Overall'; - if (showFilterContext) { - return i18n.translate('xpack.ml.explorer.overallSwimlaneUnfilteredLabel', { - defaultMessage: '{label} (unfiltered)', - values: { label: mlEscape(label) }, - }); - } else { - return label === '' ? `${EMPTY_FIELD_VALUE_LABEL}` : mlEscape(label); - } - }) - .on('click', () => { - if (selection && typeof selection.lanes !== 'undefined') { - this.swimLaneSelectionCompleted(); - } - }) - .each(function (this: HTMLElement) { - if (swimlaneData.fieldName !== undefined) { - d3.select(this) - .on('mouseover', (value) => { - that.props.tooltipService.show( - [ - { skipHeader: true } as ChartTooltipValue, - { - label: swimlaneData.fieldName!, - value: value === '' ? EMPTY_FIELD_VALUE_LABEL : value, - // @ts-ignore - seriesIdentifier: { key: value }, - valueAccessor: 'fieldName', - }, - ], - this, - { - x: laneLabelWidth, - y: 0, - } - ); - }) - .on('mouseout', () => { - that.props.tooltipService.hide(); - }) - .attr( - 'aria-label', - (value) => `${mlEscape(swimlaneData.fieldName!)}: ${mlEscape(value)}` - ); - } - }); - - const cellsContainer = d3LanesEnter.append('div').classed('cells-container', true); - - function getBucketScore(lane: string, time: number): number { - let bucketScore = 0; - const point = points.find((p) => { - return p.value > 0 && p.laneLabel === lane && p.time === time; - }); - if (typeof point !== 'undefined') { - bucketScore = point.value; - } - return bucketScore; - } - - // TODO - mark if zoomed in to bucket width? - let time = startTime; - Array(numBuckets || 0) - .fill(null) - .forEach((v, i) => { - const cell = cellsContainer - .append('div') - .classed('sl-cell', true) - .style('width', `${cellWidth}px`) - .attr('data-lane-label', (label: string) => mlEscape(label)) - .attr('data-time', time) - .attr('data-bucket-score', (lane: string) => { - return getBucketScore(lane, time); - }) - // use a factory here to bind the `time` and `i` values - // of this iteration to the event. - .on('mouseover', cellMouseOverFactory(time, i)) - .on('mouseleave', cellMouseleave) - .each(function (this: NodeWithData, laneLabel: string) { - this.__clickData__ = { - bucketScore: getBucketScore(laneLabel, time), - laneLabel, - swimlaneType, - time, - }; - }); - - // calls itself with each() to get access to lane (= d3 data) - cell.append('div').each(function (this: HTMLElement, lane: string) { - const el = d3.select(this); - - let color = 'none'; - let bucketScore = 0; - - const point = points.find((p) => { - return p.value > 0 && p.laneLabel === lane && p.time === time; - }); - - if (typeof point !== 'undefined') { - bucketScore = point.value; - color = colorScore(bucketScore); - el.classed('sl-cell-inner', true).style('background-color', color); - } else { - el.classed('sl-cell-inner-dragselect', true); - } - }); - - time += stepSecs; - }); - - // ['x-axis'] is just a placeholder so we have an array of 1. - const laneTimes = swimlanes - .selectAll('.time-tick-labels') - .data(['x-axis']) - .enter() - .append('div') - .classed('time-tick-labels', true); - - // height of .time-tick-labels - const svgHeight = 25; - const svg = laneTimes.append('svg').attr('width', chartWidth).attr('height', svgHeight); - - const xAxis = d3.svg - .axis() - .scale(xAxisScale) - .ticks(numTicksForDateFormat(chartWidth, xAxisTickFormat)) - .tickFormat((tick) => moment(tick).format(xAxisTickFormat)); - - const gAxis = svg.append('g').attr('class', 'x axis').call(xAxis); - - // remove overlapping labels - let overlapCheck = 0; - gAxis.selectAll('g.tick').each(function (this: HTMLElement) { - const tick = d3.select(this); - const xTransform = d3.transform(tick.attr('transform')).translate[0]; - const tickWidth = (tick.select('text').node() as SVGGraphicsElement).getBBox().width; - const xMinOffset = xTransform - tickWidth / 2; - const xMaxOffset = xTransform + tickWidth / 2; - // if the tick label overlaps the previous label - // (or overflows the chart to the left), remove it; - // otherwise pick that label's offset as the new offset to check against - if (xMinOffset < overlapCheck) { - tick.remove(); - } else { - overlapCheck = xTransform + tickWidth / 2; - } - // if the last tick label overflows the chart to the right, remove it - if (xMaxOffset > chartWidth) { - tick.remove(); - } - }); - - this.swimlaneRenderDoneListener(); - - this.highlightSwimLaneCells(selection); - } - - shouldComponentUpdate(nextProps: ExplorerSwimlaneProps) { - return ( - this.props.chartWidth !== nextProps.chartWidth || - !isEqual(this.props.swimlaneData, nextProps.swimlaneData) || - !isEqual(nextProps.selection, this.selection) - ); - } - - /** - * Listener for click events in the swim lane and execute a prop callback. - * @param selectedCellsUpdate - */ - swimLaneSelectionCompleted(selectedCellsUpdate?: AppStateSelectedCells) { - // If selectedCells is an empty object we clear any existing selection, - // otherwise we save the new selection in AppState and update the Explorer. - this.highlightSwimLaneCells(selectedCellsUpdate); - - if (!selectedCellsUpdate) { - this.props.onCellsSelection(); - } else { - this.props.onCellsSelection(selectedCellsUpdate); - } - } - - /** - * Listens to render updates of the swim lanes to update dragSelect - */ - swimlaneRenderDoneListener() { - this.dragSelect.clearSelection(); - this.dragSelect.setSelectables(document.querySelectorAll(`#${this.rootNodeId} .sl-cell`)); - } - - setSwimlaneSelectActive(active: boolean) { - if (this.isSwimlaneSelectActive && !active && this.disableDragSelectOnMouseLeave) { - this.dragSelect.stop(); - this.isSwimlaneSelectActive = active; - return; - } - if (!this.isSwimlaneSelectActive && active) { - this.dragSelect.start(); - this.dragSelect.clearSelection(); - this.dragSelect.setSelectables(document.querySelectorAll(`#${this.rootNodeId} .sl-cell`)); - this.isSwimlaneSelectActive = active; - } - } - - render() { - const { swimlaneType } = this.props; - - return ( -
-
-
- ); - } -} diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx index 235e5d0f20f8..0a2791edb9c5 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useCallback, useRef, useState } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { EuiText, EuiLoadingChart, @@ -15,47 +15,131 @@ import { } from '@elastic/eui'; import { throttle } from 'lodash'; -import { ExplorerSwimlane, ExplorerSwimlaneProps } from './explorer_swimlane'; +import { + Chart, + Settings, + Heatmap, + HeatmapElementEvent, + ElementClickListener, + TooltipValue, + HeatmapSpec, +} from '@elastic/charts'; +import moment from 'moment'; +import { HeatmapBrushEvent } from '@elastic/charts/dist/chart_types/heatmap/layout/types/config_types'; -import { MlTooltipComponent } from '../components/chart_tooltip'; +import { i18n } from '@kbn/i18n'; +import { TooltipSettings } from '@elastic/charts/dist/specs/settings'; import { SwimLanePagination } from './swimlane_pagination'; -import { ViewBySwimLaneData } from './explorer_utils'; +import { AppStateSelectedCells, OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils'; +import { ANOMALY_THRESHOLD, SEVERITY_COLORS } from '../../../common'; +import { TimeBuckets as TimeBucketsClass } from '../util/time_buckets'; +import { SWIMLANE_TYPE, SwimlaneType } from './explorer_constants'; +import { mlEscape } from '../util/string_utils'; +import { FormattedTooltip } from '../components/chart_tooltip/chart_tooltip'; +import { formatHumanReadableDateTime } from '../../../common/util/date_utils'; +import { getFormattedSeverityScore } from '../../../common/util/anomaly_utils'; + +import './_explorer.scss'; +import { EMPTY_FIELD_VALUE_LABEL } from '../timeseriesexplorer/components/entity_control/entity_control'; /** * Ignore insignificant resize, e.g. browser scrollbar appearance. */ const RESIZE_IGNORED_DIFF_PX = 20; const RESIZE_THROTTLE_TIME_MS = 500; +const CELL_HEIGHT = 30; +const LEGEND_HEIGHT = 34; +const Y_AXIS_HEIGHT = 24; export function isViewBySwimLaneData(arg: any): arg is ViewBySwimLaneData { return arg && arg.hasOwnProperty('cardinality'); } /** - * Anomaly swim lane container responsible for handling resizing, pagination and injecting - * tooltip service. - * - * @param children - * @param onResize - * @param perPage - * @param fromPage - * @param swimlaneLimit - * @param onPaginationChange - * @param props - * @constructor + * Provides a custom tooltip for the anomaly swim lane chart. */ -export const SwimlaneContainer: FC< - Omit & { - onResize: (width: number) => void; - fromPage?: number; - perPage?: number; - swimlaneLimit?: number; - onPaginationChange?: (arg: { perPage?: number; fromPage?: number }) => void; - isLoading: boolean; - noDataWarning: string | JSX.Element | null; +const SwimLaneTooltip = (fieldName?: string): FC<{ values: TooltipValue[] }> => ({ values }) => { + const tooltipData: TooltipValue[] = []; + + if (values.length === 1 && fieldName) { + // Y-axis tooltip for viewBy swim lane + const [yAxis] = values; + // @ts-ignore + tooltipData.push({ skipHeader: true }); + tooltipData.push({ + label: fieldName, + value: yAxis.value, + // @ts-ignore + seriesIdentifier: { + key: yAxis.value, + }, + }); + } else if (values.length === 3) { + // Cell tooltip + const [xAxis, yAxis, cell] = values; + + // Display date using same format as Kibana visualizations. + const formattedDate = formatHumanReadableDateTime(parseInt(xAxis.value, 10)); + tooltipData.push({ label: formattedDate } as TooltipValue); + + if (fieldName !== undefined) { + tooltipData.push({ + label: fieldName, + value: yAxis.value, + // @ts-ignore + seriesIdentifier: { + key: yAxis.value, + }, + }); + } + tooltipData.push({ + label: i18n.translate('xpack.ml.explorer.swimlane.maxAnomalyScoreLabel', { + defaultMessage: 'Max anomaly score', + }), + value: cell.formattedValue, + color: cell.color, + // @ts-ignore + seriesIdentifier: { + key: cell.value, + }, + }); } -> = ({ - children, + + return ; +}; + +export interface SwimlaneProps { + filterActive?: boolean; + maskAll?: boolean; + timeBuckets: InstanceType; + swimlaneData: OverallSwimlaneData | ViewBySwimLaneData; + swimlaneType: SwimlaneType; + selection?: AppStateSelectedCells; + onCellsSelection: (payload?: AppStateSelectedCells) => void; + 'data-test-subj'?: string; + onResize: (width: number) => void; + fromPage?: number; + perPage?: number; + swimlaneLimit?: number; + onPaginationChange?: (arg: { perPage?: number; fromPage?: number }) => void; + isLoading: boolean; + noDataWarning: string | JSX.Element | null; + /** + * Unique id of the chart + */ + id: string; + /** + * Enables/disables timeline on the X-axis. + */ + showTimeline?: boolean; +} + +/** + * Anomaly swim lane container responsible for handling resizing, pagination and + * providing swim lane vis with required props. + */ +export const SwimlaneContainer: FC = ({ + id, onResize, perPage, fromPage, @@ -63,10 +147,20 @@ export const SwimlaneContainer: FC< onPaginationChange, isLoading, noDataWarning, - ...props + filterActive, + swimlaneData, + swimlaneType, + selection, + onCellsSelection, + timeBuckets, + maskAll, + showTimeline = true, + 'data-test-subj': dataTestSubj, }) => { const [chartWidth, setChartWidth] = useState(0); - const wrapperRef = useRef(null); + + // Holds the container height for previously fetched data + const containerHeightRef = useRef(); const resizeHandler = useCallback( throttle((e: { width: number; height: number }) => { @@ -80,11 +174,28 @@ export const SwimlaneContainer: FC< [chartWidth] ); - const showSwimlane = - props.swimlaneData && - props.swimlaneData.laneLabels && - props.swimlaneData.laneLabels.length > 0 && - props.swimlaneData.points.length > 0; + const swimLanePoints = useMemo(() => { + const showFilterContext = filterActive === true && swimlaneType === SWIMLANE_TYPE.OVERALL; + + if (!swimlaneData?.points) { + return []; + } + + return swimlaneData.points + .map((v) => { + const formatted = { ...v, time: v.time * 1000 }; + if (showFilterContext) { + formatted.laneLabel = i18n.translate('xpack.ml.explorer.overallSwimlaneUnfilteredLabel', { + defaultMessage: '{label} (unfiltered)', + values: { label: mlEscape(v.laneLabel) }, + }); + } + return formatted; + }) + .filter((v) => v.value > 0); + }, [swimlaneData?.points, filterActive, swimlaneType]); + + const showSwimlane = swimlaneData?.laneLabels?.length > 0 && swimLanePoints.length > 0; const isPaginationVisible = (showSwimlane || isLoading) && @@ -93,67 +204,230 @@ export const SwimlaneContainer: FC< fromPage && perPage; + const rowsCount = swimlaneData?.laneLabels?.length ?? 0; + + const containerHeight = useMemo(() => { + // Persists container height during loading to prevent page from jumping + return isLoading + ? containerHeightRef.current + : rowsCount * CELL_HEIGHT + LEGEND_HEIGHT + (showTimeline ? Y_AXIS_HEIGHT : 0); + }, [isLoading, rowsCount, showTimeline]); + + useEffect(() => { + if (!isLoading) { + containerHeightRef.current = containerHeight; + } + }, [isLoading, containerHeight]); + + const highlightedData: HeatmapSpec['highlightedData'] = useMemo(() => { + if (!selection || !swimlaneData) return; + + if ( + (swimlaneType !== selection.type || + (swimlaneData?.fieldName !== undefined && + swimlaneData.fieldName !== selection.viewByFieldName)) && + filterActive === false + ) { + // Not this swim lane which was selected. + return; + } + + return { x: selection.times.map((v) => v * 1000), y: selection.lanes }; + }, [selection, swimlaneData, swimlaneType]); + + const swimLaneConfig: HeatmapSpec['config'] = useMemo( + () => + showSwimlane + ? { + onBrushEnd: (e: HeatmapBrushEvent) => { + onCellsSelection({ + lanes: e.y as string[], + times: e.x.map((v) => (v as number) / 1000), + type: swimlaneType, + viewByFieldName: swimlaneData.fieldName, + }); + }, + grid: { + cellHeight: { + min: CELL_HEIGHT, + max: CELL_HEIGHT, + }, + stroke: { + width: 1, + color: '#D3DAE6', + }, + }, + cell: { + maxWidth: 'fill', + maxHeight: 'fill', + label: { + visible: false, + }, + border: { + stroke: '#D3DAE6', + strokeWidth: 0, + }, + }, + yAxisLabel: { + visible: true, + width: 170, + // eui color subdued + fill: `#6a717d`, + padding: 8, + formatter: (laneLabel: string) => { + return laneLabel === '' ? EMPTY_FIELD_VALUE_LABEL : laneLabel; + }, + }, + xAxisLabel: { + visible: showTimeline, + // eui color subdued + fill: `#98A2B3`, + formatter: (v: number) => { + timeBuckets.setInterval(`${swimlaneData.interval}s`); + const a = timeBuckets.getScaledDateFormat(); + return moment(v).format(a); + }, + }, + brushMask: { + fill: 'rgb(247 247 247 / 50%)', + }, + maxLegendHeight: LEGEND_HEIGHT, + } + : {}, + [showSwimlane, swimlaneType, swimlaneData?.fieldName] + ); + + // @ts-ignore + const onElementClick: ElementClickListener = useCallback( + (e: HeatmapElementEvent[]) => { + const cell = e[0][0]; + const startTime = (cell.datum.x as number) / 1000; + const payload = { + lanes: [String(cell.datum.y)], + times: [startTime, startTime + swimlaneData.interval], + type: swimlaneType, + viewByFieldName: swimlaneData.fieldName, + }; + onCellsSelection(payload); + }, + [swimlaneType, swimlaneData?.fieldName, swimlaneData?.interval] + ); + + const tooltipOptions: TooltipSettings = useMemo( + () => ({ + placement: 'auto', + fallbackPlacements: ['left'], + boundary: 'chart', + customTooltip: SwimLaneTooltip(swimlaneData?.fieldName), + }), + [swimlaneData?.fieldName] + ); + + // A resize observer is required to compute the bucket span based on the chart width to fetch the data accordingly return ( - <> - - {(resizeRef) => ( - { - resizeRef(el); + + {(resizeRef) => ( + + - -
- - {showSwimlane && !isLoading && ( - - {(tooltipService) => ( - - )} - - )} - {isLoading && ( - - - - )} - {!isLoading && !showSwimlane && ( - {noDataWarning}} - /> - )} - -
-
+
+ {showSwimlane && !isLoading && ( + + + + + )} - {isPaginationVisible && ( - - + + + )} + {!isLoading && !showSwimlane && ( + {noDataWarning}} /> - - )} - - )} - - + )} +
+
+ + {isPaginationVisible && ( + + + + )} +
+ )} +
); }; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx index 0291fa1564a2..d638e2c23146 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx @@ -115,6 +115,7 @@ export const EmbeddableSwimLaneContainer: FC = ( data-test-subj="mlAnomalySwimlaneEmbeddableWrapper" >