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`] = `" "`;
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"
>