[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
This commit is contained in:
Dima Arnautov 2020-10-06 20:51:23 +02:00 committed by GitHub
parent e31ec7eb54
commit 827f0c06fe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 476 additions and 1332 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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';

View file

@ -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';

View file

@ -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;
}
}

View file

@ -1,7 +1,6 @@
.mlChartTooltip {
@include euiToolTipStyle('s');
@include euiFontSizeXS;
position: absolute;
padding: 0;
transition: opacity $euiAnimSpeedNormal;
pointer-events: none;

View file

@ -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 (
<div className="mlChartTooltip">
{tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && (
<div className="mlChartTooltip__header">{renderHeader(tooltipData[0])}</div>
)}
{tooltipData.length > 1 && (
<div className="mlChartTooltip__list">
{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) => <div key={v}>{v}</div>)
: value;
return (
<div
key={`${seriesIdentifier.key}__${valueAccessor}`}
className={classes}
style={{
borderLeftColor: color,
}}
>
<EuiFlexGroup>
<EuiFlexItem className="eui-textBreakWord mlChartTooltip__label" grow={false}>
{label}
</EuiFlexItem>
<EuiFlexItem className="eui-textBreakAll mlChartTooltip__value">
{renderValue}
</EuiFlexItem>
</EuiFlexGroup>
</div>
);
})}
</div>
)}
</div>
);
};
/**
* Tooltip component bundled with the {@link ChartTooltipService}
*/
const Tooltip: FC<{ service: ChartTooltipService }> = React.memo(({ service }) => {
const [tooltipData, setData] = useState<TooltipData>([]);
const refCallback = useRef<ChildrenArg['triggerRef']>();
@ -57,50 +108,9 @@ const Tooltip: FC<{ service: ChartTooltipService }> = React.memo(({ service }) =
<div
{...getTooltipProps({
ref: tooltipRef,
className: 'mlChartTooltip',
})}
>
{tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && (
<div className="mlChartTooltip__header">{renderHeader(tooltipData[0])}</div>
)}
{tooltipData.length > 1 && (
<div className="mlChartTooltip__list">
{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) => <div key={v}>{v}</div>)
: value;
return (
<div
key={`${seriesIdentifier.key}__${valueAccessor}`}
className={classes}
style={{
borderLeftColor: color,
}}
>
<EuiFlexGroup>
<EuiFlexItem
className="eui-textBreakWord mlChartTooltip__label"
grow={false}
>
{label}
</EuiFlexItem>
<EuiFlexItem className="eui-textBreakAll mlChartTooltip__value">
{renderValue}
</EuiFlexItem>
</EuiFlexGroup>
</div>
);
})}
</div>
)}
<FormattedTooltip tooltipData={tooltipData} />
</div>
);
}) as TooltipTriggerProps['tooltip'],

File diff suppressed because one or more lines are too long

View file

@ -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 {
.mlSwimLaneContainer {
/* Override legend styles */
.echLegendListContainer {
height: 34px !important;
}
.sl-cell-inner,
.sl-cell-inner-dragselect {
opacity: 0.6 !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;
}
}

View file

@ -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<AnomalyTimelineProps> = React.memo(
/>
</EuiFormRow>
</EuiFlexItem>
{selectedCells ? (
<EuiFlexItem grow={false}>
<EuiButtonEmpty size="xs" onClick={setSelectedCells.bind(null, undefined)}>
<FormattedMessage
id="xpack.ml.explorer.clearSelectionLabel"
defaultMessage="Clear selection"
/>
</EuiButtonEmpty>
</EuiFlexItem>
) : null}
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<div className="panel-sub-title">
{viewByLoadedForTimeFormatted && (
@ -211,6 +222,7 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
<EuiSpacer size="m" />
<SwimlaneContainer
id="overall"
data-test-subj="mlAnomalyExplorerSwimlaneOverall"
filterActive={filterActive}
maskAll={maskAll}
@ -222,12 +234,14 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
onResize={explorerService.setSwimlaneContainerWidth}
isLoading={loading}
noDataWarning={<NoOverallData />}
showTimeline={false}
/>
<EuiSpacer size="m" />
{viewBySwimlaneOptions.length > 0 && (
<SwimlaneContainer
id="view_by"
data-test-subj="mlAnomalyExplorerSwimlaneViewBy"
filterActive={filterActive}
maskAll={

View file

@ -1,126 +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.
*/
import mockOverallSwimlaneData from './__mocks__/mock_overall_swimlane.json';
import moment from 'moment-timezone';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import React from 'react';
import { ExplorerSwimlane } from './explorer_swimlane';
import { TimeBuckets as TimeBucketsClass } from '../util/time_buckets';
import { ChartTooltipService } from '../components/chart_tooltip';
import { OverallSwimlaneData } from './explorer_utils';
jest.mock('d3', () => {
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<typeof TimeBucketsClass>;
const tooltipService = ({
show: jest.fn(),
hide: jest.fn(),
} as unknown) as ChartTooltipService;
return {
timeBuckets,
swimlaneData,
tooltipService,
parentRef: {} as React.RefObject<HTMLDivElement>,
};
}
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(
<ExplorerSwimlane
chartWidth={mockChartWidth}
timeBuckets={mocks.timeBuckets}
onCellsSelection={jest.fn()}
swimlaneData={mocks.swimlaneData}
swimlaneType="overall"
tooltipService={mocks.tooltipService}
parentRef={mocks.parentRef}
/>
);
expect(wrapper.html()).toBe(
'<div class="mlExplorerSwimlane"><div class="ml-swimlanes ml-swimlane-overall" id="test-gen-id"><div class="time-tick-labels"><svg width="800" height="25"><g class="x axis"><path class="domain" d="MNaN,6V0H0V6"></path></g></svg></div></div></div>'
);
// 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(
<ExplorerSwimlane
chartWidth={mockChartWidth}
timeBuckets={mocks.timeBuckets}
onCellsSelection={jest.fn()}
swimlaneData={mockOverallSwimlaneData}
swimlaneType="overall"
tooltipService={mocks.tooltipService}
parentRef={mocks.parentRef}
/>
);
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);
});
});

View file

@ -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<typeof TimeBucketsClass>;
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<HTMLDivElement>;
}
export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
// 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<HTMLDivElement>();
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 === '' ? `<i>${EMPTY_FIELD_VALUE_LABEL}</i>` : 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 (
<div
className="mlExplorerSwimlane"
onMouseEnter={this.setSwimlaneSelectActive.bind(this, true)}
onMouseLeave={this.setSwimlaneSelectActive.bind(this, false)}
data-test-subj={this.props['data-test-subj'] ?? null}
>
<div
className={`ml-swimlanes ml-swimlane-${swimlaneType}`}
ref={this.rootNode}
id={this.rootNodeId}
/>
</div>
);
}
}

View file

@ -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<ExplorerSwimlaneProps, 'chartWidth' | 'tooltipService' | 'parentRef'> & {
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 <FormattedTooltip tooltipData={tooltipData} />;
};
export interface SwimlaneProps {
filterActive?: boolean;
maskAll?: boolean;
timeBuckets: InstanceType<typeof TimeBucketsClass>;
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<SwimlaneProps> = ({
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<number>(0);
const wrapperRef = useRef<HTMLDivElement>(null);
// Holds the container height for previously fetched data
const containerHeightRef = useRef<number>();
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;
return (
<>
<EuiResizeObserver onResize={resizeHandler}>
{(resizeRef) => (
<EuiFlexGroup
gutterSize={'none'}
direction={'column'}
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
ref={(el) => {
resizeRef(el);
}}
data-test-subj="mlSwimLaneContainer"
>
<EuiFlexItem style={{ width: '100%', overflowY: 'auto' }} grow={false}>
<div ref={wrapperRef}>
<EuiText color="subdued" size="s">
{showSwimlane && !isLoading && (
<MlTooltipComponent>
{(tooltipService) => (
<ExplorerSwimlane
{...props}
chartWidth={chartWidth}
tooltipService={tooltipService}
parentRef={wrapperRef}
/>
)}
</MlTooltipComponent>
)}
{isLoading && (
<EuiText textAlign={'center'}>
<EuiLoadingChart
size="xl"
mono={true}
data-test-subj="mlSwimLaneLoadingIndicator"
/>
</EuiText>
)}
{!isLoading && !showSwimlane && (
<EuiEmptyPrompt
titleSize="xs"
style={{ padding: 0 }}
title={<h2>{noDataWarning}</h2>}
/>
)}
</EuiText>
</div>
</EuiFlexItem>
const rowsCount = swimlaneData?.laneLabels?.length ?? 0;
{isPaginationVisible && (
<EuiFlexItem grow={false}>
<SwimLanePagination
cardinality={swimlaneLimit!}
fromPage={fromPage!}
perPage={perPage!}
onPaginationChange={onPaginationChange!}
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 (
<EuiResizeObserver onResize={resizeHandler}>
{(resizeRef) => (
<EuiFlexGroup
gutterSize={'none'}
direction={'column'}
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
ref={resizeRef}
data-test-subj="mlSwimLaneContainer"
>
<EuiFlexItem
style={{
width: '100%',
overflowY: 'auto',
}}
grow={false}
>
<div
style={{ height: `${containerHeight}px`, position: 'relative' }}
data-test-subj={dataTestSubj}
>
{showSwimlane && !isLoading && (
<Chart className={'mlSwimLaneContainer'}>
<Settings
onElementClick={onElementClick}
showLegend
legendPosition="top"
xDomain={{
min: swimlaneData.earliest * 1000,
max: swimlaneData.latest * 1000,
minInterval: swimlaneData.interval * 1000,
}}
tooltip={tooltipOptions}
/>
<Heatmap
id={id}
colorScale="threshold"
ranges={[
ANOMALY_THRESHOLD.LOW,
ANOMALY_THRESHOLD.WARNING,
ANOMALY_THRESHOLD.MINOR,
ANOMALY_THRESHOLD.MAJOR,
ANOMALY_THRESHOLD.CRITICAL,
]}
colors={[
SEVERITY_COLORS.BLANK,
SEVERITY_COLORS.LOW,
SEVERITY_COLORS.WARNING,
SEVERITY_COLORS.MINOR,
SEVERITY_COLORS.MAJOR,
SEVERITY_COLORS.CRITICAL,
]}
data={swimLanePoints}
xAccessor="time"
yAccessor="laneLabel"
valueAccessor="value"
highlightedData={highlightedData}
valueFormatter={getFormattedSeverityScore}
xScaleType="time"
ySortPredicate="dataIndex"
config={swimLaneConfig}
/>
</Chart>
)}
{isLoading && (
<EuiText
textAlign={'center'}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%,-50%)',
}}
>
<EuiLoadingChart
size="xl"
mono={true}
data-test-subj="mlSwimLaneLoadingIndicator"
/>
</EuiText>
)}
{!isLoading && !showSwimlane && (
<EuiEmptyPrompt
titleSize="xs"
style={{ padding: 0 }}
title={<h2>{noDataWarning}</h2>}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
)}
</EuiResizeObserver>
</>
)}
</div>
</EuiFlexItem>
{isPaginationVisible && (
<EuiFlexItem grow={false}>
<SwimLanePagination
cardinality={swimlaneLimit!}
fromPage={fromPage!}
perPage={perPage!}
onPaginationChange={onPaginationChange!}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
)}
</EuiResizeObserver>
);
};

View file

@ -115,6 +115,7 @@ export const EmbeddableSwimLaneContainer: FC<ExplorerSwimlaneContainerProps> = (
data-test-subj="mlAnomalySwimlaneEmbeddableWrapper"
>
<SwimlaneContainer
id={id}
data-test-subj={`mlSwimLaneEmbeddable_${embeddableContext.id}`}
timeBuckets={timeBuckets}
swimlaneData={swimlaneData!}

View file

@ -88,7 +88,7 @@ export function MachineLearningAnomalyExplorerProvider({ getService }: FtrProvid
);
await testSubjects.clickWhenNotDisabled('mlAddAndEditDashboardButton');
const embeddable = await testSubjects.find('mlAnomalySwimlaneEmbeddableWrapper');
const swimlane = await embeddable.findByClassName('ml-swimlanes');
const swimlane = await embeddable.findByClassName('mlSwimLaneContainer');
expect(await swimlane.isDisplayed()).to.eql(
true,
'Anomaly swimlane should be displayed in dashboard'

View file

@ -1218,10 +1218,10 @@
dependencies:
"@elastic/apm-rum-core" "^5.7.0"
"@elastic/charts@23.1.1":
version "23.1.1"
resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-23.1.1.tgz#01f51d80f4ba7291dd68fe75f23a71f77e44dce9"
integrity sha512-qoDBzo4r2Aeh2JmbpWxkN+xI/PZ7HyNr91HLqewadMCnSR2tqviBrUySttX/SpBxE/0VoN4gd/T8vcjCt2a/GQ==
"@elastic/charts@23.2.1":
version "23.2.1"
resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-23.2.1.tgz#1f48629fe4597655a7f119fd019c4d5a2cbaf252"
integrity sha512-L2jUPAWwE0xLry6DcqcngVLCa9R32pfz5jW1fyOJRWSq1Fay2swOw4joBe8PmHpvl2s8EwWi9qWBORR1z3hUeQ==
dependencies:
"@popperjs/core" "^2.4.0"
chroma-js "^2.1.0"
@ -3845,11 +3845,6 @@
resolved "https://registry.yarnpkg.com/@types/delete-empty/-/delete-empty-2.0.0.tgz#1647ae9e68f708a6ba778531af667ec55bc61964"
integrity sha512-sq+kwx8zA9BSugT9N+Jr8/uWjbHMZ+N/meJEzRyT3gmLq/WMtx/iSIpvdpmBUi/cvXl6Kzpvve8G2ESkabFwmg==
"@types/dragselect@^1.13.1":
version "1.13.1"
resolved "https://registry.yarnpkg.com/@types/dragselect/-/dragselect-1.13.1.tgz#f19b7b41063a7c9d5963194c83c3c364e84d46ee"
integrity sha512-3m0fvSM0cSs0DXvprytV/ZY92hNX3jJuEb/vkdqU+4QMzV2jxYKgBFTuaT2fflqbmfzUqHHIkGP55WIuigElQw==
"@types/ejs@^3.0.4":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@types/ejs/-/ejs-3.0.4.tgz#8851fcdedb96e410fbb24f83b8be6763ef9afa77"
@ -11025,11 +11020,6 @@ downgrade-root@^1.0.0:
default-uid "^1.0.0"
is-root "^1.0.0"
dragselect@1.13.1:
version "1.13.1"
resolved "https://registry.yarnpkg.com/dragselect/-/dragselect-1.13.1.tgz#aa4166e1164b51ed5ee0cd89e0c5310a9c35be6a"
integrity sha512-spfUz6/sNnlY4fF/OxPBwaKLa5hVz6V+fq5XhVuD+h47RAkA75TMkfvr4AoWUh5Ufq3V1oIAbfu+sjc9QbewoA==
dtrace-provider@~0.8:
version "0.8.8"
resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.8.8.tgz#2996d5490c37e1347be263b423ed7b297fb0d97e"