mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
e31ec7eb54
commit
827f0c06fe
17 changed files with 476 additions and 1332 deletions
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
.mlChartTooltip {
|
||||
@include euiToolTipStyle('s');
|
||||
@include euiFontSizeXS;
|
||||
position: absolute;
|
||||
padding: 0;
|
||||
transition: opacity $euiAnimSpeedNormal;
|
||||
pointer-events: none;
|
||||
|
|
|
@ -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
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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!}
|
||||
|
|
|
@ -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'
|
||||
|
|
18
yarn.lock
18
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue