-
{title}
-
+
+ {title && (
+
+ {title}
+
+ )}
+
+ tickFormat(value),
+ type: TooltipType.VerticalCursor,
+ }}
+ externalPointerEvents={{ tooltip: { visible: false } }}
+ />
+
+
+
+ {renderYAxis(chart)}
+
+ {chart.map((data, index) => {
+ const visData = { ...data };
+ const SeriesComponent = data.bars ? BarSeriesComponent : AreaSeriesComponent;
+
+ if (!visData.color) {
+ visData.color = colors[index % colors.length];
+ }
+ return (
+
+ );
+ })}
+
);
-}
+};
// default export required for React.Lazy
// eslint-disable-next-line import/no-default-export
diff --git a/src/plugins/discover/public/application/helpers/format_number_with_commas.ts b/src/plugins/vis_type_timelion/public/helpers/active_cursor.ts
similarity index 59%
rename from src/plugins/discover/public/application/helpers/format_number_with_commas.ts
rename to src/plugins/vis_type_timelion/public/helpers/active_cursor.ts
index 0dd85804ef92e..7f7f62fd6a9da 100644
--- a/src/plugins/discover/public/application/helpers/format_number_with_commas.ts
+++ b/src/plugins/vis_type_timelion/public/helpers/active_cursor.ts
@@ -6,11 +6,7 @@
* Side Public License, v 1.
*/
-const COMMA_SEPARATOR_RE = /(\d)(?=(\d{3})+(?!\d))/g;
+import { Subject } from 'rxjs';
+import { PointerEvent } from '@elastic/charts';
-/**
- * Converts a number to a string and adds commas
- * as thousands separators
- */
-export const formatNumWithCommas = (input: number) =>
- String(input).replace(COMMA_SEPARATOR_RE, '$1,');
+export const activeCursor$ = new Subject
();
diff --git a/src/plugins/vis_type_timelion/public/helpers/chart_constants.ts b/src/plugins/vis_type_timelion/public/helpers/chart_constants.ts
new file mode 100644
index 0000000000000..b530ec98bd8a1
--- /dev/null
+++ b/src/plugins/vis_type_timelion/public/helpers/chart_constants.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+export const colors = [
+ '#01A4A4',
+ '#C66',
+ '#D0D102',
+ '#616161',
+ '#00A1CB',
+ '#32742C',
+ '#F18D05',
+ '#113F8C',
+ '#61AE24',
+ '#D70060',
+];
diff --git a/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts b/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts
index 8ef527a181e8c..1ee834b7d30ed 100644
--- a/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts
+++ b/src/plugins/vis_type_timelion/public/helpers/panel_utils.ts
@@ -6,18 +6,18 @@
* Side Public License, v 1.
*/
-import { cloneDeep, defaults, mergeWith, compact } from 'lodash';
-import $ from 'jquery';
-import moment, { Moment } from 'moment-timezone';
-
-import { TimefilterContract } from 'src/plugins/data/public';
-import { IUiSettingsClient } from 'kibana/public';
+import moment from 'moment-timezone';
+import { Position, AxisSpec } from '@elastic/charts';
+import type { TimefilterContract } from 'src/plugins/data/public';
+import type { IUiSettingsClient } from 'kibana/public';
import { calculateInterval } from '../../common/lib';
import { xaxisFormatterProvider } from './xaxis_formatter';
-import { Series } from './timelion_request_handler';
+import { tickFormatters } from './tick_formatters';
+
+import type { Series } from './timelion_request_handler';
-export interface Axis {
+export interface IAxis {
delta?: number;
max?: number;
min?: number;
@@ -30,87 +30,26 @@ export interface Axis {
tickLength: number;
timezone: string;
tickDecimals?: number;
- tickFormatter: ((val: number) => string) | ((val: number, axis: Axis) => string);
- tickGenerator?(axis: Axis): number[];
- units?: { type: string };
-}
-
-interface TimeRangeBounds {
- min: Moment | undefined;
- max: Moment | undefined;
+ tickFormatter: (val: number) => string;
+ tickGenerator?(axis: IAxis): number[];
+ units?: { type: string; prefix: string; suffix: string };
+ domain?: {
+ min?: number;
+ max?: number;
+ };
+ position?: Position;
+ axisLabel?: string;
}
-export const ACTIVE_CURSOR = 'ACTIVE_CURSOR_TIMELION';
-export const eventBus = $({});
-
-const colors = [
- '#01A4A4',
- '#C66',
- '#D0D102',
- '#616161',
- '#00A1CB',
- '#32742C',
- '#F18D05',
- '#113F8C',
- '#61AE24',
- '#D70060',
-];
-
-const SERIES_ID_ATTR = 'data-series-id';
-
-function buildSeriesData(chart: Series[], options: jquery.flot.plotOptions) {
- const seriesData = chart.map((series: Series, seriesIndex: number) => {
- const newSeries: Series = cloneDeep(
- defaults(series, {
- shadowSize: 0,
- lines: {
- lineWidth: 3,
- },
- })
- );
-
- newSeries._id = seriesIndex;
-
- if (series.color) {
- const span = document.createElement('span');
- span.style.color = series.color;
- newSeries.color = span.style.color;
- }
-
- if (series._hide) {
- newSeries.data = [];
- newSeries.stack = false;
- newSeries.label = `(hidden) ${series.label}`;
- }
-
- if (series._global) {
- mergeWith(options, series._global, (objVal, srcVal) => {
- // This is kind of gross, it means that you can't replace a global value with a null
- // best you can do is an empty string. Deal with it.
- if (objVal == null) {
- return srcVal;
- }
- if (srcVal == null) {
- return objVal;
- }
- });
- }
-
- return newSeries;
- });
+export const validateLegendPositionValue = (position: string) => /^(n|s)(e|w)$/s.test(position);
- return compact(seriesData);
-}
-
-function buildOptions(
+export const createTickFormat = (
intervalValue: string,
timefilter: TimefilterContract,
- uiSettings: IUiSettingsClient,
- clientWidth = 0,
- showGrid?: boolean
-) {
+ uiSettings: IUiSettingsClient
+) => {
// Get the X-axis tick format
- const time: TimeRangeBounds = timefilter.getBounds();
+ const time = timefilter.getBounds();
const interval = calculateInterval(
(time.min && time.min.valueOf()) || 0,
(time.max && time.max.valueOf()) || 0,
@@ -120,61 +59,75 @@ function buildOptions(
);
const format = xaxisFormatterProvider(uiSettings)(interval);
- const tickLetterWidth = 7;
- const tickPadding = 45;
-
- const options = {
- xaxis: {
- mode: 'time',
- tickLength: 5,
- timezone: 'browser',
- // Calculate how many ticks can fit on the axis
- ticks: Math.floor(clientWidth / (format.length * tickLetterWidth + tickPadding)),
- // Use moment to format ticks so we get timezone correction
- tickFormatter: (val: number) => moment(val).format(format),
- },
- selection: {
- mode: 'x',
- color: '#ccc',
- },
- crosshair: {
- mode: 'x',
- color: '#C66',
- lineWidth: 2,
- },
- colors,
- grid: {
- show: showGrid,
- borderWidth: 0,
- borderColor: null,
- margin: 10,
- hoverable: true,
- autoHighlight: false,
- },
- legend: {
- backgroundColor: 'rgb(255,255,255,0)',
- position: 'nw',
- labelBoxBorderColor: 'rgb(255,255,255,0)',
- labelFormatter(label: string, series: { _id: number }) {
- const wrapperSpan = document.createElement('span');
- const labelSpan = document.createElement('span');
- const numberSpan = document.createElement('span');
-
- wrapperSpan.setAttribute('class', 'ngLegendValue');
- wrapperSpan.setAttribute(SERIES_ID_ATTR, `${series._id}`);
-
- labelSpan.appendChild(document.createTextNode(label));
- numberSpan.setAttribute('class', 'ngLegendValueNumber');
-
- wrapperSpan.appendChild(labelSpan);
- wrapperSpan.appendChild(numberSpan);
-
- return wrapperSpan.outerHTML;
- },
- },
- } as jquery.flot.plotOptions & { yaxes?: Axis[] };
-
- return options;
-}
+ return (val: number) => moment(val).format(format);
+};
+
+/** While we support 2 versions of the timeline, we need this adapter. **/
+export const MAIN_GROUP_ID = 1;
+
+export const withStaticPadding = (domain: AxisSpec['domain']): AxisSpec['domain'] =>
+ (({
+ ...domain,
+ padding: 50,
+ paddingUnit: 'pixel',
+ } as unknown) as AxisSpec['domain']);
+
+const adaptYaxisParams = (yaxis: IAxis) => {
+ const y = { ...yaxis };
+
+ if (y.units) {
+ const formatters = tickFormatters(y);
+ y.tickFormatter = formatters[y.units.type as keyof typeof formatters];
+ } else if (yaxis.tickDecimals) {
+ y.tickFormatter = (val: number) => val.toFixed(yaxis.tickDecimals);
+ }
+
+ return {
+ title: y.axisLabel,
+ position: y.position,
+ tickFormat: y.tickFormatter,
+ domain: withStaticPadding({
+ fit: y.min === undefined && y.max === undefined,
+ min: y.min,
+ max: y.max,
+ }),
+ };
+};
+
+const extractYAxisForSeries = (series: Series) => {
+ const yaxis = (series._global?.yaxes ?? []).reduce(
+ (acc: IAxis, item: IAxis) => ({
+ ...acc,
+ ...item,
+ }),
+ {}
+ );
+
+ if (Object.keys(yaxis).length) {
+ return adaptYaxisParams(yaxis);
+ }
+};
+
+export const extractAllYAxis = (series: Series[]) => {
+ return series.reduce((acc, data, index) => {
+ const yaxis = extractYAxisForSeries(data);
+ const groupId = `${data.yaxis ? data.yaxis : MAIN_GROUP_ID}`;
+
+ if (acc.every((axis) => axis.groupId !== groupId)) {
+ acc.push({
+ groupId,
+ domain: withStaticPadding({
+ fit: false,
+ }),
+ id: (yaxis?.position || Position.Left) + index,
+ position: Position.Left,
+ ...yaxis,
+ });
+ } else if (yaxis) {
+ const axisOptionIndex = acc.findIndex((axis) => axis.groupId === groupId);
+ acc[axisOptionIndex] = { ...acc[axisOptionIndex], ...yaxis };
+ }
-export { buildSeriesData, buildOptions, SERIES_ID_ATTR, colors };
+ return acc;
+ }, [] as Array>);
+};
diff --git a/src/plugins/vis_type_timelion/public/helpers/tick_formatters.test.ts b/src/plugins/vis_type_timelion/public/helpers/tick_formatters.test.ts
index 03b7c21706957..9980644c0f941 100644
--- a/src/plugins/vis_type_timelion/public/helpers/tick_formatters.test.ts
+++ b/src/plugins/vis_type_timelion/public/helpers/tick_formatters.test.ts
@@ -7,25 +7,26 @@
*/
import { tickFormatters } from './tick_formatters';
+import type { IAxis } from './panel_utils';
-describe('Tick Formatters', function () {
+describe('Tick Formatters', () => {
let formatters: any;
beforeEach(function () {
- formatters = tickFormatters();
+ formatters = tickFormatters({} as IAxis);
});
- describe('Bits mode', function () {
+ describe('Bits mode', () => {
let bitFormatter: any;
beforeEach(function () {
bitFormatter = formatters.bits;
});
- it('is a function', function () {
+ it('is a function', () => {
expect(bitFormatter).toEqual(expect.any(Function));
});
- it('formats with b/kb/mb/gb', function () {
+ it('formats with b/kb/mb/gb', () => {
expect(bitFormatter(7)).toEqual('7b');
expect(bitFormatter(4 * 1000)).toEqual('4kb');
expect(bitFormatter(4.1 * 1000 * 1000)).toEqual('4.1mb');
@@ -40,24 +41,24 @@ describe('Tick Formatters', function () {
});
});
- describe('Bits/s mode', function () {
+ describe('Bits/s mode', () => {
let bitsFormatter: any;
beforeEach(function () {
bitsFormatter = formatters['bits/s'];
});
- it('is a function', function () {
+ it('is a function', () => {
expect(bitsFormatter).toEqual(expect.any(Function));
});
- it('formats with b/kb/mb/gb', function () {
+ it('formats with b/kb/mb/gb', () => {
expect(bitsFormatter(7)).toEqual('7b/s');
expect(bitsFormatter(4 * 1000)).toEqual('4kb/s');
expect(bitsFormatter(4.1 * 1000 * 1000)).toEqual('4.1mb/s');
expect(bitsFormatter(3 * 1000 * 1000 * 1000)).toEqual('3gb/s');
});
- it('formats negative values with b/kb/mb/gb', function () {
+ it('formats negative values with b/kb/mb/gb', () => {
expect(bitsFormatter(-7)).toEqual('-7b/s');
expect(bitsFormatter(-4 * 1000)).toEqual('-4kb/s');
expect(bitsFormatter(-4.1 * 1000 * 1000)).toEqual('-4.1mb/s');
@@ -65,24 +66,24 @@ describe('Tick Formatters', function () {
});
});
- describe('Bytes mode', function () {
+ describe('Bytes mode', () => {
let byteFormatter: any;
beforeEach(function () {
byteFormatter = formatters.bytes;
});
- it('is a function', function () {
+ it('is a function', () => {
expect(byteFormatter).toEqual(expect.any(Function));
});
- it('formats with B/KB/MB/GB', function () {
+ it('formats with B/KB/MB/GB', () => {
expect(byteFormatter(10)).toEqual('10B');
expect(byteFormatter(10 * 1024)).toEqual('10KB');
expect(byteFormatter(10.2 * 1024 * 1024)).toEqual('10.2MB');
expect(byteFormatter(3 * 1024 * 1024 * 1024)).toEqual('3GB');
});
- it('formats negative values with B/KB/MB/GB', function () {
+ it('formats negative values with B/KB/MB/GB', () => {
expect(byteFormatter(-10)).toEqual('-10B');
expect(byteFormatter(-10 * 1024)).toEqual('-10KB');
expect(byteFormatter(-10.2 * 1024 * 1024)).toEqual('-10.2MB');
@@ -90,24 +91,24 @@ describe('Tick Formatters', function () {
});
});
- describe('Bytes/s mode', function () {
+ describe('Bytes/s mode', () => {
let bytesFormatter: any;
beforeEach(function () {
bytesFormatter = formatters['bytes/s'];
});
- it('is a function', function () {
+ it('is a function', () => {
expect(bytesFormatter).toEqual(expect.any(Function));
});
- it('formats with B/KB/MB/GB', function () {
+ it('formats with B/KB/MB/GB', () => {
expect(bytesFormatter(10)).toEqual('10B/s');
expect(bytesFormatter(10 * 1024)).toEqual('10KB/s');
expect(bytesFormatter(10.2 * 1024 * 1024)).toEqual('10.2MB/s');
expect(bytesFormatter(3 * 1024 * 1024 * 1024)).toEqual('3GB/s');
});
- it('formats negative values with B/KB/MB/GB', function () {
+ it('formats negative values with B/KB/MB/GB', () => {
expect(bytesFormatter(-10)).toEqual('-10B/s');
expect(bytesFormatter(-10 * 1024)).toEqual('-10KB/s');
expect(bytesFormatter(-10.2 * 1024 * 1024)).toEqual('-10.2MB/s');
@@ -115,108 +116,105 @@ describe('Tick Formatters', function () {
});
});
- describe('Currency mode', function () {
+ describe('Currency mode', () => {
let currencyFormatter: any;
beforeEach(function () {
currencyFormatter = formatters.currency;
});
- it('is a function', function () {
+ it('is a function', () => {
expect(currencyFormatter).toEqual(expect.any(Function));
});
- it('formats with $ by default', function () {
+ it('formats with $ by default', () => {
const axis = {
- options: {
- units: {},
- },
+ units: {},
};
- expect(currencyFormatter(10.2, axis)).toEqual('$10.20');
+ formatters = tickFormatters(axis as IAxis);
+ currencyFormatter = formatters.currency;
+ expect(currencyFormatter(10.2)).toEqual('$10.20');
});
- it('accepts currency in ISO 4217', function () {
+ it('accepts currency in ISO 4217', () => {
const axis = {
- options: {
- units: {
- prefix: 'CNY',
- },
+ units: {
+ prefix: 'CNY',
},
};
-
- expect(currencyFormatter(10.2, axis)).toEqual('CN¥10.20');
+ formatters = tickFormatters(axis as IAxis);
+ currencyFormatter = formatters.currency;
+ expect(currencyFormatter(10.2)).toEqual('CN¥10.20');
});
});
- describe('Percent mode', function () {
+ describe('Percent mode', () => {
let percentFormatter: any;
beforeEach(function () {
percentFormatter = formatters.percent;
});
- it('is a function', function () {
+ it('is a function', () => {
expect(percentFormatter).toEqual(expect.any(Function));
});
- it('formats with %', function () {
+ it('formats with %', () => {
const axis = {
- options: {
- units: {},
- },
+ units: {},
};
- expect(percentFormatter(0.1234, axis)).toEqual('12%');
+ formatters = tickFormatters(axis as IAxis);
+ percentFormatter = formatters.percent;
+ expect(percentFormatter(0.1234)).toEqual('12%');
});
- it('formats with % with decimal precision', function () {
+ it('formats with % with decimal precision', () => {
const tickDecimals = 3;
const tickDecimalShift = 2;
const axis = {
tickDecimals: tickDecimals + tickDecimalShift,
- options: {
- units: {
- tickDecimalsShift: tickDecimalShift,
- },
+ units: {
+ tickDecimalsShift: tickDecimalShift,
},
- };
- expect(percentFormatter(0.12345, axis)).toEqual('12.345%');
+ } as unknown;
+ formatters = tickFormatters(axis as IAxis);
+ percentFormatter = formatters.percent;
+ expect(percentFormatter(0.12345)).toEqual('12.345%');
});
});
- describe('Custom mode', function () {
+ describe('Custom mode', () => {
let customFormatter: any;
beforeEach(function () {
customFormatter = formatters.custom;
});
- it('is a function', function () {
+ it('is a function', () => {
expect(customFormatter).toEqual(expect.any(Function));
});
- it('accepts prefix and suffix', function () {
+ it('accepts prefix and suffix', () => {
const axis = {
- options: {
- units: {
- prefix: 'prefix',
- suffix: 'suffix',
- },
+ units: {
+ prefix: 'prefix',
+ suffix: 'suffix',
},
tickDecimals: 1,
};
-
- expect(customFormatter(10.2, axis)).toEqual('prefix10.2suffix');
+ formatters = tickFormatters(axis as IAxis);
+ customFormatter = formatters.custom;
+ expect(customFormatter(10.2)).toEqual('prefix10.2suffix');
});
- it('correctly renders small values', function () {
+ it('correctly renders small values', () => {
const axis = {
- options: {
- units: {
- prefix: 'prefix',
- suffix: 'suffix',
- },
+ units: {
+ prefix: 'prefix',
+ suffix: 'suffix',
},
tickDecimals: 3,
};
-
- expect(customFormatter(0.00499999999999999, axis)).toEqual('prefix0.005suffix');
+ formatters = tickFormatters(axis as IAxis);
+ customFormatter = formatters.custom;
+ expect(customFormatter(0.00499999999999999)).toEqual('prefix0.005suffix');
});
});
});
diff --git a/src/plugins/vis_type_timelion/public/helpers/tick_formatters.ts b/src/plugins/vis_type_timelion/public/helpers/tick_formatters.ts
index 56fa17e74f275..eb37e76e1f95d 100644
--- a/src/plugins/vis_type_timelion/public/helpers/tick_formatters.ts
+++ b/src/plugins/vis_type_timelion/public/helpers/tick_formatters.ts
@@ -8,9 +8,9 @@
import { get } from 'lodash';
-import { Axis } from './panel_utils';
+import { IAxis } from './panel_utils';
-function baseTickFormatter(value: number, axis: Axis) {
+function baseTickFormatter(value: number, axis: IAxis) {
const factor = axis.tickDecimals ? Math.pow(10, axis.tickDecimals) : 1;
const formatted = '' + Math.round(value * factor) / factor;
@@ -45,21 +45,20 @@ function unitFormatter(divisor: number, units: string[]) {
};
}
-export function tickFormatters() {
+export function tickFormatters(axis: IAxis) {
return {
bits: unitFormatter(1000, ['b', 'kb', 'mb', 'gb', 'tb', 'pb']),
'bits/s': unitFormatter(1000, ['b/s', 'kb/s', 'mb/s', 'gb/s', 'tb/s', 'pb/s']),
bytes: unitFormatter(1024, ['B', 'KB', 'MB', 'GB', 'TB', 'PB']),
'bytes/s': unitFormatter(1024, ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s']),
- currency(val: number, axis: Axis) {
+ currency(val: number) {
return val.toLocaleString('en', {
style: 'currency',
- currency: (axis && axis.options && axis.options.units.prefix) || 'USD',
+ currency: (axis && axis.units && axis.units.prefix) || 'USD',
});
},
- percent(val: number, axis: Axis) {
- let precision =
- get(axis, 'tickDecimals', 0) - get(axis, 'options.units.tickDecimalsShift', 0);
+ percent(val: number) {
+ let precision = get(axis, 'tickDecimals', 0) - get(axis, 'units.tickDecimalsShift', 0);
// toFixed only accepts values between 0 and 20
if (precision < 0) {
precision = 0;
@@ -69,10 +68,10 @@ export function tickFormatters() {
return (val * 100).toFixed(precision) + '%';
},
- custom(val: number, axis: Axis) {
+ custom(val: number) {
const formattedVal = baseTickFormatter(val, axis);
- const prefix = axis && axis.options && axis.options.units.prefix;
- const suffix = axis && axis.options && axis.options.units.suffix;
+ const prefix = axis && axis.units && axis.units.prefix;
+ const suffix = axis && axis.units && axis.units.suffix;
return prefix + formattedVal + suffix;
},
};
diff --git a/src/plugins/vis_type_timelion/public/helpers/tick_generator.ts b/src/plugins/vis_type_timelion/public/helpers/tick_generator.ts
index af559d5aaac2b..6ffdda0bafdb6 100644
--- a/src/plugins/vis_type_timelion/public/helpers/tick_generator.ts
+++ b/src/plugins/vis_type_timelion/public/helpers/tick_generator.ts
@@ -6,14 +6,14 @@
* Side Public License, v 1.
*/
-import { Axis } from './panel_utils';
+import { IAxis } from './panel_utils';
export function generateTicksProvider() {
function floorInBase(n: number, base: number) {
return base * Math.floor(n / base);
}
- function generateTicks(axis: Axis) {
+ function generateTicks(axis: IAxis) {
const returnTicks = [];
let tickSize = 2;
let delta = axis.delta || 0;
@@ -46,5 +46,5 @@ export function generateTicksProvider() {
return returnTicks;
}
- return (axis: Axis) => generateTicks(axis);
+ return (axis: IAxis) => generateTicks(axis);
}
diff --git a/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts b/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts
index 7e8f28bd32b2f..fe5e9183976fa 100644
--- a/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts
+++ b/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts
@@ -12,6 +12,7 @@ import { TimelionVisDependencies } from '../plugin';
import { getTimezone } from './get_timezone';
import { TimelionVisParams } from '../timelion_vis_fn';
import { getDataSearch } from '../helpers/plugin_services';
+import { VisSeries } from '../../common/vis_data';
interface Stats {
cacheCount: number;
@@ -21,17 +22,13 @@ interface Stats {
sheetTime: number;
}
-export interface Series {
- _global?: boolean;
+export interface Series extends VisSeries {
+ _global?: Record;
_hide?: boolean;
_id?: number;
_title?: string;
- color?: string;
- data: Array>;
fit: string;
- label: string;
split: string;
- stack?: boolean;
type: string;
}
diff --git a/src/plugins/vis_type_timelion/public/index.ts b/src/plugins/vis_type_timelion/public/index.ts
index fa257907a176d..1ab572b497212 100644
--- a/src/plugins/vis_type_timelion/public/index.ts
+++ b/src/plugins/vis_type_timelion/public/index.ts
@@ -9,16 +9,26 @@
import { PluginInitializerContext } from 'kibana/public';
import { TimelionVisPlugin as Plugin } from './plugin';
+import { tickFormatters } from './legacy/tick_formatters';
+import { getTimezone } from './helpers/get_timezone';
+import { xaxisFormatterProvider } from './helpers/xaxis_formatter';
+import { generateTicksProvider } from './helpers/tick_generator';
+import { DEFAULT_TIME_FORMAT, calculateInterval } from '../common/lib';
+import { parseTimelionExpressionAsync } from '../common/parser_async';
+
export function plugin(initializerContext: PluginInitializerContext) {
return new Plugin(initializerContext);
}
-export { getTimezone } from './helpers/get_timezone';
-export { tickFormatters } from './helpers/tick_formatters';
-export { xaxisFormatterProvider } from './helpers/xaxis_formatter';
-export { generateTicksProvider } from './helpers/tick_generator';
-
-export { DEFAULT_TIME_FORMAT, calculateInterval } from '../common/lib';
-export { parseTimelionExpressionAsync } from '../common/parser_async';
+// This export should be removed on removing Timeline APP
+export const _LEGACY_ = {
+ DEFAULT_TIME_FORMAT,
+ calculateInterval,
+ parseTimelionExpressionAsync,
+ tickFormatters,
+ getTimezone,
+ xaxisFormatterProvider,
+ generateTicksProvider,
+};
export { VisTypeTimelionPluginStart, VisTypeTimelionPluginSetup } from './plugin';
diff --git a/src/plugins/vis_type_timelion/public/legacy/panel_utils.ts b/src/plugins/vis_type_timelion/public/legacy/panel_utils.ts
new file mode 100644
index 0000000000000..5dd8431a5a2ab
--- /dev/null
+++ b/src/plugins/vis_type_timelion/public/legacy/panel_utils.ts
@@ -0,0 +1,168 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { cloneDeep, defaults, mergeWith, compact } from 'lodash';
+import $ from 'jquery';
+import moment, { Moment } from 'moment-timezone';
+
+import { TimefilterContract } from 'src/plugins/data/public';
+import { IUiSettingsClient } from 'kibana/public';
+
+import { calculateInterval } from '../../common/lib';
+import { xaxisFormatterProvider } from '../helpers/xaxis_formatter';
+import { Series } from '../helpers/timelion_request_handler';
+import { colors } from '../helpers/chart_constants';
+
+export interface LegacyAxis {
+ delta?: number;
+ max?: number;
+ min?: number;
+ mode: string;
+ options?: {
+ units: { prefix: string; suffix: string };
+ };
+ tickSize?: number;
+ ticks: number;
+ tickLength: number;
+ timezone: string;
+ tickDecimals?: number;
+ tickFormatter: ((val: number) => string) | ((val: number, axis: LegacyAxis) => string);
+ tickGenerator?(axis: LegacyAxis): number[];
+ units?: { type: string };
+}
+
+interface TimeRangeBounds {
+ min: Moment | undefined;
+ max: Moment | undefined;
+}
+
+export const ACTIVE_CURSOR = 'ACTIVE_CURSOR_TIMELION';
+export const eventBus = $({});
+
+const SERIES_ID_ATTR = 'data-series-id';
+
+function buildSeriesData(chart: Series[], options: jquery.flot.plotOptions) {
+ const seriesData = chart.map((series: Series, seriesIndex: number) => {
+ const newSeries: Series = cloneDeep(
+ defaults(series, {
+ shadowSize: 0,
+ lines: {
+ lineWidth: 3,
+ },
+ })
+ );
+
+ newSeries._id = seriesIndex;
+
+ if (series.color) {
+ const span = document.createElement('span');
+ span.style.color = series.color;
+ newSeries.color = span.style.color;
+ }
+
+ if (series._hide) {
+ newSeries.data = [];
+ newSeries.stack = false;
+ newSeries.label = `(hidden) ${series.label}`;
+ }
+
+ if (series._global) {
+ mergeWith(options, series._global, (objVal, srcVal) => {
+ // This is kind of gross, it means that you can't replace a global value with a null
+ // best you can do is an empty string. Deal with it.
+ if (objVal == null) {
+ return srcVal;
+ }
+ if (srcVal == null) {
+ return objVal;
+ }
+ });
+ }
+
+ return newSeries;
+ });
+
+ return compact(seriesData);
+}
+
+function buildOptions(
+ intervalValue: string,
+ timefilter: TimefilterContract,
+ uiSettings: IUiSettingsClient,
+ clientWidth = 0,
+ showGrid?: boolean
+) {
+ // Get the X-axis tick format
+ const time: TimeRangeBounds = timefilter.getBounds();
+ const interval = calculateInterval(
+ (time.min && time.min.valueOf()) || 0,
+ (time.max && time.max.valueOf()) || 0,
+ uiSettings.get('timelion:target_buckets') || 200,
+ intervalValue,
+ uiSettings.get('timelion:min_interval') || '1ms'
+ );
+ const format = xaxisFormatterProvider(uiSettings)(interval);
+
+ const tickLetterWidth = 7;
+ const tickPadding = 45;
+
+ const options = {
+ xaxis: {
+ mode: 'time',
+ tickLength: 5,
+ timezone: 'browser',
+ // Calculate how many ticks can fit on the axis
+ ticks: Math.floor(clientWidth / (format.length * tickLetterWidth + tickPadding)),
+ // Use moment to format ticks so we get timezone correction
+ tickFormatter: (val: number) => moment(val).format(format),
+ },
+ selection: {
+ mode: 'x',
+ color: '#ccc',
+ },
+ crosshair: {
+ mode: 'x',
+ color: '#C66',
+ lineWidth: 2,
+ },
+ colors,
+ grid: {
+ show: showGrid,
+ borderWidth: 0,
+ borderColor: null,
+ margin: 10,
+ hoverable: true,
+ autoHighlight: false,
+ },
+ legend: {
+ backgroundColor: 'rgb(255,255,255,0)',
+ position: 'nw',
+ labelBoxBorderColor: 'rgb(255,255,255,0)',
+ labelFormatter(label: string, series: { _id: number }) {
+ const wrapperSpan = document.createElement('span');
+ const labelSpan = document.createElement('span');
+ const numberSpan = document.createElement('span');
+
+ wrapperSpan.setAttribute('class', 'ngLegendValue');
+ wrapperSpan.setAttribute(SERIES_ID_ATTR, `${series._id}`);
+
+ labelSpan.appendChild(document.createTextNode(label));
+ numberSpan.setAttribute('class', 'ngLegendValueNumber');
+
+ wrapperSpan.appendChild(labelSpan);
+ wrapperSpan.appendChild(numberSpan);
+
+ return wrapperSpan.outerHTML;
+ },
+ },
+ } as jquery.flot.plotOptions & { yaxes?: LegacyAxis[] };
+
+ return options;
+}
+
+export { buildSeriesData, buildOptions, SERIES_ID_ATTR };
diff --git a/src/plugins/vis_type_timelion/public/legacy/tick_formatters.test.ts b/src/plugins/vis_type_timelion/public/legacy/tick_formatters.test.ts
new file mode 100644
index 0000000000000..03b7c21706957
--- /dev/null
+++ b/src/plugins/vis_type_timelion/public/legacy/tick_formatters.test.ts
@@ -0,0 +1,222 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { tickFormatters } from './tick_formatters';
+
+describe('Tick Formatters', function () {
+ let formatters: any;
+
+ beforeEach(function () {
+ formatters = tickFormatters();
+ });
+
+ describe('Bits mode', function () {
+ let bitFormatter: any;
+ beforeEach(function () {
+ bitFormatter = formatters.bits;
+ });
+
+ it('is a function', function () {
+ expect(bitFormatter).toEqual(expect.any(Function));
+ });
+
+ it('formats with b/kb/mb/gb', function () {
+ expect(bitFormatter(7)).toEqual('7b');
+ expect(bitFormatter(4 * 1000)).toEqual('4kb');
+ expect(bitFormatter(4.1 * 1000 * 1000)).toEqual('4.1mb');
+ expect(bitFormatter(3 * 1000 * 1000 * 1000)).toEqual('3gb');
+ });
+
+ it('formats negative values with b/kb/mb/gb', () => {
+ expect(bitFormatter(-7)).toEqual('-7b');
+ expect(bitFormatter(-4 * 1000)).toEqual('-4kb');
+ expect(bitFormatter(-4.1 * 1000 * 1000)).toEqual('-4.1mb');
+ expect(bitFormatter(-3 * 1000 * 1000 * 1000)).toEqual('-3gb');
+ });
+ });
+
+ describe('Bits/s mode', function () {
+ let bitsFormatter: any;
+ beforeEach(function () {
+ bitsFormatter = formatters['bits/s'];
+ });
+
+ it('is a function', function () {
+ expect(bitsFormatter).toEqual(expect.any(Function));
+ });
+
+ it('formats with b/kb/mb/gb', function () {
+ expect(bitsFormatter(7)).toEqual('7b/s');
+ expect(bitsFormatter(4 * 1000)).toEqual('4kb/s');
+ expect(bitsFormatter(4.1 * 1000 * 1000)).toEqual('4.1mb/s');
+ expect(bitsFormatter(3 * 1000 * 1000 * 1000)).toEqual('3gb/s');
+ });
+
+ it('formats negative values with b/kb/mb/gb', function () {
+ expect(bitsFormatter(-7)).toEqual('-7b/s');
+ expect(bitsFormatter(-4 * 1000)).toEqual('-4kb/s');
+ expect(bitsFormatter(-4.1 * 1000 * 1000)).toEqual('-4.1mb/s');
+ expect(bitsFormatter(-3 * 1000 * 1000 * 1000)).toEqual('-3gb/s');
+ });
+ });
+
+ describe('Bytes mode', function () {
+ let byteFormatter: any;
+ beforeEach(function () {
+ byteFormatter = formatters.bytes;
+ });
+
+ it('is a function', function () {
+ expect(byteFormatter).toEqual(expect.any(Function));
+ });
+
+ it('formats with B/KB/MB/GB', function () {
+ expect(byteFormatter(10)).toEqual('10B');
+ expect(byteFormatter(10 * 1024)).toEqual('10KB');
+ expect(byteFormatter(10.2 * 1024 * 1024)).toEqual('10.2MB');
+ expect(byteFormatter(3 * 1024 * 1024 * 1024)).toEqual('3GB');
+ });
+
+ it('formats negative values with B/KB/MB/GB', function () {
+ expect(byteFormatter(-10)).toEqual('-10B');
+ expect(byteFormatter(-10 * 1024)).toEqual('-10KB');
+ expect(byteFormatter(-10.2 * 1024 * 1024)).toEqual('-10.2MB');
+ expect(byteFormatter(-3 * 1024 * 1024 * 1024)).toEqual('-3GB');
+ });
+ });
+
+ describe('Bytes/s mode', function () {
+ let bytesFormatter: any;
+ beforeEach(function () {
+ bytesFormatter = formatters['bytes/s'];
+ });
+
+ it('is a function', function () {
+ expect(bytesFormatter).toEqual(expect.any(Function));
+ });
+
+ it('formats with B/KB/MB/GB', function () {
+ expect(bytesFormatter(10)).toEqual('10B/s');
+ expect(bytesFormatter(10 * 1024)).toEqual('10KB/s');
+ expect(bytesFormatter(10.2 * 1024 * 1024)).toEqual('10.2MB/s');
+ expect(bytesFormatter(3 * 1024 * 1024 * 1024)).toEqual('3GB/s');
+ });
+
+ it('formats negative values with B/KB/MB/GB', function () {
+ expect(bytesFormatter(-10)).toEqual('-10B/s');
+ expect(bytesFormatter(-10 * 1024)).toEqual('-10KB/s');
+ expect(bytesFormatter(-10.2 * 1024 * 1024)).toEqual('-10.2MB/s');
+ expect(bytesFormatter(-3 * 1024 * 1024 * 1024)).toEqual('-3GB/s');
+ });
+ });
+
+ describe('Currency mode', function () {
+ let currencyFormatter: any;
+ beforeEach(function () {
+ currencyFormatter = formatters.currency;
+ });
+
+ it('is a function', function () {
+ expect(currencyFormatter).toEqual(expect.any(Function));
+ });
+
+ it('formats with $ by default', function () {
+ const axis = {
+ options: {
+ units: {},
+ },
+ };
+ expect(currencyFormatter(10.2, axis)).toEqual('$10.20');
+ });
+
+ it('accepts currency in ISO 4217', function () {
+ const axis = {
+ options: {
+ units: {
+ prefix: 'CNY',
+ },
+ },
+ };
+
+ expect(currencyFormatter(10.2, axis)).toEqual('CN¥10.20');
+ });
+ });
+
+ describe('Percent mode', function () {
+ let percentFormatter: any;
+ beforeEach(function () {
+ percentFormatter = formatters.percent;
+ });
+
+ it('is a function', function () {
+ expect(percentFormatter).toEqual(expect.any(Function));
+ });
+
+ it('formats with %', function () {
+ const axis = {
+ options: {
+ units: {},
+ },
+ };
+ expect(percentFormatter(0.1234, axis)).toEqual('12%');
+ });
+
+ it('formats with % with decimal precision', function () {
+ const tickDecimals = 3;
+ const tickDecimalShift = 2;
+ const axis = {
+ tickDecimals: tickDecimals + tickDecimalShift,
+ options: {
+ units: {
+ tickDecimalsShift: tickDecimalShift,
+ },
+ },
+ };
+ expect(percentFormatter(0.12345, axis)).toEqual('12.345%');
+ });
+ });
+
+ describe('Custom mode', function () {
+ let customFormatter: any;
+ beforeEach(function () {
+ customFormatter = formatters.custom;
+ });
+
+ it('is a function', function () {
+ expect(customFormatter).toEqual(expect.any(Function));
+ });
+
+ it('accepts prefix and suffix', function () {
+ const axis = {
+ options: {
+ units: {
+ prefix: 'prefix',
+ suffix: 'suffix',
+ },
+ },
+ tickDecimals: 1,
+ };
+
+ expect(customFormatter(10.2, axis)).toEqual('prefix10.2suffix');
+ });
+
+ it('correctly renders small values', function () {
+ const axis = {
+ options: {
+ units: {
+ prefix: 'prefix',
+ suffix: 'suffix',
+ },
+ },
+ tickDecimals: 3,
+ };
+
+ expect(customFormatter(0.00499999999999999, axis)).toEqual('prefix0.005suffix');
+ });
+ });
+});
diff --git a/src/plugins/vis_type_timelion/public/legacy/tick_formatters.ts b/src/plugins/vis_type_timelion/public/legacy/tick_formatters.ts
new file mode 100644
index 0000000000000..950226968287b
--- /dev/null
+++ b/src/plugins/vis_type_timelion/public/legacy/tick_formatters.ts
@@ -0,0 +1,79 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { get } from 'lodash';
+
+import type { LegacyAxis } from './panel_utils';
+
+function baseTickFormatter(value: number, axis: LegacyAxis) {
+ const factor = axis.tickDecimals ? Math.pow(10, axis.tickDecimals) : 1;
+ const formatted = '' + Math.round(value * factor) / factor;
+
+ // If tickDecimals was specified, ensure that we have exactly that
+ // much precision; otherwise default to the value's own precision.
+
+ if (axis.tickDecimals != null) {
+ const decimal = formatted.indexOf('.');
+ const precision = decimal === -1 ? 0 : formatted.length - decimal - 1;
+ if (precision < axis.tickDecimals) {
+ return (
+ (precision ? formatted : formatted + '.') +
+ ('' + factor).substr(1, axis.tickDecimals - precision)
+ );
+ }
+ }
+
+ return formatted;
+}
+
+function unitFormatter(divisor: number, units: string[]) {
+ return (val: number) => {
+ let index = 0;
+ const isNegative = val < 0;
+ val = Math.abs(val);
+ while (val >= divisor && index < units.length) {
+ val /= divisor;
+ index++;
+ }
+ const value = (Math.round(val * 100) / 100) * (isNegative ? -1 : 1);
+ return `${value}${units[index]}`;
+ };
+}
+
+export function tickFormatters() {
+ return {
+ bits: unitFormatter(1000, ['b', 'kb', 'mb', 'gb', 'tb', 'pb']),
+ 'bits/s': unitFormatter(1000, ['b/s', 'kb/s', 'mb/s', 'gb/s', 'tb/s', 'pb/s']),
+ bytes: unitFormatter(1024, ['B', 'KB', 'MB', 'GB', 'TB', 'PB']),
+ 'bytes/s': unitFormatter(1024, ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s']),
+ currency(val: number, axis: LegacyAxis) {
+ return val.toLocaleString('en', {
+ style: 'currency',
+ currency: (axis && axis.options && axis.options.units.prefix) || 'USD',
+ });
+ },
+ percent(val: number, axis: LegacyAxis) {
+ let precision =
+ get(axis, 'tickDecimals', 0) - get(axis, 'options.units.tickDecimalsShift', 0);
+ // toFixed only accepts values between 0 and 20
+ if (precision < 0) {
+ precision = 0;
+ } else if (precision > 20) {
+ precision = 20;
+ }
+
+ return (val * 100).toFixed(precision) + '%';
+ },
+ custom(val: number, axis: LegacyAxis) {
+ const formattedVal = baseTickFormatter(val, axis);
+ const prefix = axis && axis.options && axis.options.units.prefix;
+ const suffix = axis && axis.options && axis.options.units.suffix;
+ return prefix + formattedVal + suffix;
+ },
+ };
+}
diff --git a/src/plugins/vis_type_timelion/public/legacy/timelion_vis.scss b/src/plugins/vis_type_timelion/public/legacy/timelion_vis.scss
new file mode 100644
index 0000000000000..c4d591bc82cad
--- /dev/null
+++ b/src/plugins/vis_type_timelion/public/legacy/timelion_vis.scss
@@ -0,0 +1,60 @@
+.timChart {
+ height: 100%;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+
+ // Custom Jquery FLOT / schema selectors
+ // Cannot change at the moment
+
+ .chart-top-title {
+ @include euiFontSizeXS;
+ flex: 0;
+ text-align: center;
+ font-weight: $euiFontWeightBold;
+ }
+
+ .chart-canvas {
+ min-width: 100%;
+ flex: 1;
+ overflow: hidden;
+ }
+
+ .legendLabel {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow-x: hidden;
+ line-height: normal;
+ }
+
+ .legendColorBox {
+ vertical-align: middle;
+ }
+
+ .ngLegendValue {
+ color: $euiTextColor;
+ cursor: pointer;
+
+ &:focus,
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .ngLegendValueNumber {
+ margin-left: $euiSizeXS;
+ margin-right: $euiSizeXS;
+ font-weight: $euiFontWeightBold;
+ }
+
+ .flot-tick-label {
+ font-size: $euiFontSizeXS;
+ color: $euiColorDarkShade;
+ }
+}
+
+.timChart__legendCaption {
+ color: $euiTextColor;
+ white-space: nowrap;
+ font-weight: $euiFontWeightBold;
+}
diff --git a/src/plugins/vis_type_timelion/public/legacy/timelion_vis_component.tsx b/src/plugins/vis_type_timelion/public/legacy/timelion_vis_component.tsx
new file mode 100644
index 0000000000000..ddac86fa73bee
--- /dev/null
+++ b/src/plugins/vis_type_timelion/public/legacy/timelion_vis_component.tsx
@@ -0,0 +1,418 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React, { useState, useEffect, useMemo, useCallback } from 'react';
+import $ from 'jquery';
+import moment from 'moment-timezone';
+import { debounce, compact, get, each, cloneDeep, last, map } from 'lodash';
+import { useResizeObserver } from '@elastic/eui';
+
+import { IInterpreterRenderHandlers } from 'src/plugins/expressions';
+import { useKibana } from '../../../kibana_react/public';
+import { DEFAULT_TIME_FORMAT } from '../../common/lib';
+
+import {
+ buildSeriesData,
+ buildOptions,
+ SERIES_ID_ATTR,
+ LegacyAxis,
+ ACTIVE_CURSOR,
+ eventBus,
+} from './panel_utils';
+
+import { Series, Sheet } from '../helpers/timelion_request_handler';
+import { colors } from '../helpers/chart_constants';
+import { tickFormatters } from './tick_formatters';
+import { generateTicksProvider } from '../helpers/tick_generator';
+
+import type { TimelionVisDependencies } from '../plugin';
+import type { RangeFilterParams } from '../../../data/common';
+
+import './timelion_vis.scss';
+
+interface CrosshairPlot extends jquery.flot.plot {
+ setCrosshair: (pos: Position) => void;
+ clearCrosshair: () => void;
+}
+
+interface TimelionVisComponentProps {
+ onBrushEvent: (rangeFilterParams: RangeFilterParams) => void;
+ interval: string;
+ seriesList: Sheet;
+ renderComplete: IInterpreterRenderHandlers['done'];
+}
+
+interface Position {
+ x: number;
+ x1: number;
+ y: number;
+ y1: number;
+ pageX: number;
+ pageY: number;
+}
+
+interface Range {
+ to: number;
+ from: number;
+}
+
+interface Ranges {
+ xaxis: Range;
+ yaxis: Range;
+}
+
+const DEBOUNCE_DELAY = 50;
+// ensure legend is the same height with or without a caption so legend items do not move around
+const emptyCaption = '
';
+
+function TimelionVisComponent({
+ interval,
+ seriesList,
+ renderComplete,
+ onBrushEvent,
+}: TimelionVisComponentProps) {
+ const kibana = useKibana();
+ const [chart, setChart] = useState(() => cloneDeep(seriesList.list));
+ const [canvasElem, setCanvasElem] = useState();
+ const [chartElem, setChartElem] = useState(null);
+
+ const [originalColorMap, setOriginalColorMap] = useState(() => new Map());
+
+ const [highlightedSeries, setHighlightedSeries] = useState(null);
+ const [focusedSeries, setFocusedSeries] = useState();
+ const [plot, setPlot] = useState();
+
+ // Used to toggle the series, and for displaying values on hover
+ const [legendValueNumbers, setLegendValueNumbers] = useState>();
+ const [legendCaption, setLegendCaption] = useState>();
+
+ const canvasRef = useCallback((node: HTMLDivElement | null) => {
+ if (node !== null) {
+ setCanvasElem(node);
+ }
+ }, []);
+
+ const elementRef = useCallback((node: HTMLDivElement | null) => {
+ if (node !== null) {
+ setChartElem(node);
+ }
+ }, []);
+
+ useEffect(
+ () => () => {
+ if (chartElem) {
+ $(chartElem).off('plotselected').off('plothover').off('mouseleave');
+ }
+ },
+ [chartElem]
+ );
+
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
+ const highlightSeries = useCallback(
+ debounce(({ currentTarget }: JQuery.TriggeredEvent) => {
+ const id = Number(currentTarget.getAttribute(SERIES_ID_ATTR));
+ if (highlightedSeries === id) {
+ return;
+ }
+
+ setHighlightedSeries(id);
+ setChart((chartState) =>
+ chartState.map((series: Series, seriesIndex: number) => {
+ series.color =
+ seriesIndex === id
+ ? originalColorMap.get(series) // color it like it was
+ : 'rgba(128,128,128,0.1)'; // mark as grey
+
+ return series;
+ })
+ );
+ }, DEBOUNCE_DELAY),
+ [originalColorMap, highlightedSeries]
+ );
+
+ const focusSeries = useCallback(
+ (event: JQuery.TriggeredEvent) => {
+ const id = Number(event.currentTarget.getAttribute(SERIES_ID_ATTR));
+ setFocusedSeries(id);
+ highlightSeries(event);
+ },
+ [highlightSeries]
+ );
+
+ const toggleSeries = useCallback(({ currentTarget }: JQuery.TriggeredEvent) => {
+ const id = Number(currentTarget.getAttribute(SERIES_ID_ATTR));
+
+ setChart((chartState) =>
+ chartState.map((series: Series, seriesIndex: number) => {
+ if (seriesIndex === id) {
+ series._hide = !series._hide;
+ }
+ return series;
+ })
+ );
+ }, []);
+
+ const updateCaption = useCallback(
+ (plotData: any) => {
+ if (canvasElem && get(plotData, '[0]._global.legend.showTime', true)) {
+ const caption = $('');
+ caption.html(emptyCaption);
+ setLegendCaption(caption);
+
+ const canvasNode = $(canvasElem);
+ canvasNode.find('div.legend table').append(caption);
+ setLegendValueNumbers(canvasNode.find('.ngLegendValueNumber'));
+
+ const legend = $(canvasElem).find('.ngLegendValue');
+ if (legend) {
+ legend.click(toggleSeries);
+ legend.focus(focusSeries);
+ legend.mouseover(highlightSeries);
+ }
+
+ // legend has been re-created. Apply focus on legend element when previously set
+ if (focusedSeries || focusedSeries === 0) {
+ canvasNode.find('div.legend table .legendLabel>span').get(focusedSeries).focus();
+ }
+ }
+ },
+ [focusedSeries, canvasElem, toggleSeries, focusSeries, highlightSeries]
+ );
+
+ const updatePlot = useCallback(
+ (chartValue: Series[], grid?: boolean) => {
+ if (canvasElem && canvasElem.clientWidth > 0 && canvasElem.clientHeight > 0) {
+ const options = buildOptions(
+ interval,
+ kibana.services.timefilter,
+ kibana.services.uiSettings,
+ chartElem?.clientWidth,
+ grid
+ );
+ const updatedSeries = buildSeriesData(chartValue, options);
+
+ if (options.yaxes) {
+ options.yaxes.forEach((yaxis: LegacyAxis) => {
+ if (yaxis && yaxis.units) {
+ const formatters = tickFormatters();
+ yaxis.tickFormatter = formatters[yaxis.units.type as keyof typeof formatters];
+ const byteModes = ['bytes', 'bytes/s'];
+ if (byteModes.includes(yaxis.units.type)) {
+ yaxis.tickGenerator = generateTicksProvider();
+ }
+ }
+ });
+ }
+
+ const newPlot = $.plot($(canvasElem), updatedSeries, options);
+ setPlot(newPlot);
+ renderComplete();
+
+ updateCaption(newPlot.getData());
+ }
+ },
+ [canvasElem, chartElem?.clientWidth, renderComplete, kibana.services, interval, updateCaption]
+ );
+
+ const dimensions = useResizeObserver(chartElem);
+
+ useEffect(() => {
+ updatePlot(chart, seriesList.render && seriesList.render.grid);
+ }, [chart, updatePlot, seriesList.render, dimensions]);
+
+ useEffect(() => {
+ const colorsSet: Array<[Series, string]> = [];
+ const newChart = seriesList.list.map((series: Series, seriesIndex: number) => {
+ const newSeries = { ...series };
+ if (!newSeries.color) {
+ const colorIndex = seriesIndex % colors.length;
+ newSeries.color = colors[colorIndex];
+ }
+ colorsSet.push([newSeries, newSeries.color]);
+ return newSeries;
+ });
+ setChart(newChart);
+ setOriginalColorMap(new Map(colorsSet));
+ }, [seriesList.list]);
+
+ const unhighlightSeries = useCallback(() => {
+ if (highlightedSeries === null) {
+ return;
+ }
+
+ setHighlightedSeries(null);
+ setFocusedSeries(null);
+
+ setChart((chartState) =>
+ chartState.map((series: Series) => {
+ series.color = originalColorMap.get(series); // reset the colors
+ return series;
+ })
+ );
+ }, [originalColorMap, highlightedSeries]);
+
+ // Shamelessly borrowed from the flotCrosshairs example
+ const setLegendNumbers = useCallback(
+ (pos: Position) => {
+ unhighlightSeries();
+
+ const axes = plot!.getAxes();
+ if (pos.x < axes.xaxis.min! || pos.x > axes.xaxis.max!) {
+ return;
+ }
+
+ const dataset = plot!.getData();
+ if (legendCaption) {
+ legendCaption.text(
+ moment(pos.x).format(get(dataset, '[0]._global.legend.timeFormat', DEFAULT_TIME_FORMAT))
+ );
+ }
+ for (let i = 0; i < dataset.length; ++i) {
+ const series = dataset[i];
+ const useNearestPoint = series.lines!.show && !series.lines!.steps;
+ const precision = get(series, '_meta.precision', 2);
+
+ // We're setting this flag on top on the series object belonging to the flot library, so we're simply casting here.
+ if ((series as { _hide?: boolean })._hide) {
+ continue;
+ }
+
+ const currentPoint = series.data.find((point: [number, number], index: number) => {
+ if (index + 1 === series.data.length) {
+ return true;
+ }
+ if (useNearestPoint) {
+ return pos.x - point[0] < series.data[index + 1][0] - pos.x;
+ } else {
+ return pos.x < series.data[index + 1][0];
+ }
+ });
+
+ const y = currentPoint[1];
+
+ if (legendValueNumbers) {
+ if (y == null) {
+ legendValueNumbers.eq(i).empty();
+ } else {
+ let label = y.toFixed(precision);
+ const formatter = ((series.yaxis as unknown) as LegacyAxis).tickFormatter;
+ if (formatter) {
+ label = formatter(Number(label), (series.yaxis as unknown) as LegacyAxis);
+ }
+ legendValueNumbers.eq(i).text(`(${label})`);
+ }
+ }
+ }
+ },
+ [plot, legendValueNumbers, unhighlightSeries, legendCaption]
+ );
+
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
+ const debouncedSetLegendNumbers = useCallback(
+ debounce(setLegendNumbers, DEBOUNCE_DELAY, {
+ maxWait: DEBOUNCE_DELAY,
+ leading: true,
+ trailing: false,
+ }),
+ [setLegendNumbers]
+ );
+
+ const clearLegendNumbers = useCallback(() => {
+ if (legendCaption) {
+ legendCaption.html(emptyCaption);
+ }
+ each(legendValueNumbers!, (num: Node) => {
+ $(num).empty();
+ });
+ }, [legendCaption, legendValueNumbers]);
+
+ const plotHover = useCallback(
+ (pos: Position) => {
+ (plot as CrosshairPlot).setCrosshair(pos);
+ debouncedSetLegendNumbers(pos);
+ },
+ [plot, debouncedSetLegendNumbers]
+ );
+
+ const plotHoverHandler = useCallback(
+ (event: JQuery.TriggeredEvent, pos: Position) => {
+ if (!plot) {
+ return;
+ }
+ plotHover(pos);
+ eventBus.trigger(ACTIVE_CURSOR, [event, pos]);
+ },
+ [plot, plotHover]
+ );
+
+ useEffect(() => {
+ const updateCursor = (_: any, event: JQuery.TriggeredEvent, pos: Position) => {
+ if (!plot) {
+ return;
+ }
+ plotHover(pos);
+ };
+
+ eventBus.on(ACTIVE_CURSOR, updateCursor);
+
+ return () => {
+ eventBus.off(ACTIVE_CURSOR, updateCursor);
+ };
+ }, [plot, plotHover]);
+
+ const mouseLeaveHandler = useCallback(() => {
+ if (!plot) {
+ return;
+ }
+ (plot as CrosshairPlot).clearCrosshair();
+ clearLegendNumbers();
+ }, [plot, clearLegendNumbers]);
+
+ const plotSelectedHandler = useCallback(
+ (event: JQuery.TriggeredEvent, ranges: Ranges) => {
+ onBrushEvent({
+ gte: ranges.xaxis.from,
+ lte: ranges.xaxis.to,
+ });
+ },
+ [onBrushEvent]
+ );
+
+ useEffect(() => {
+ if (chartElem) {
+ $(chartElem).off('plotselected').on('plotselected', plotSelectedHandler);
+ }
+ }, [chartElem, plotSelectedHandler]);
+
+ useEffect(() => {
+ if (chartElem) {
+ $(chartElem).off('mouseleave').on('mouseleave', mouseLeaveHandler);
+ }
+ }, [chartElem, mouseLeaveHandler]);
+
+ useEffect(() => {
+ if (chartElem) {
+ $(chartElem).off('plothover').on('plothover', plotHoverHandler);
+ }
+ }, [chartElem, plotHoverHandler]);
+
+ const title: string = useMemo(() => last(compact(map(seriesList.list, '_title'))) || '', [
+ seriesList.list,
+ ]);
+
+ return (
+
+ );
+}
+
+// default export required for React.Lazy
+// eslint-disable-next-line import/no-default-export
+export { TimelionVisComponent as default };
diff --git a/src/plugins/vis_type_timelion/public/plugin.ts b/src/plugins/vis_type_timelion/public/plugin.ts
index 3e9c4cf77687e..93712ae4507fe 100644
--- a/src/plugins/vis_type_timelion/public/plugin.ts
+++ b/src/plugins/vis_type_timelion/public/plugin.ts
@@ -22,6 +22,7 @@ import {
} from 'src/plugins/data/public';
import { VisualizationsSetup } from '../../visualizations/public';
+import { ChartsPluginSetup } from '../../charts/public';
import { getTimelionVisualizationConfig } from './timelion_vis_fn';
import { getTimelionVisDefinition } from './timelion_vis_type';
@@ -36,6 +37,7 @@ export interface TimelionVisDependencies extends Partial {
uiSettings: IUiSettingsClient;
http: HttpSetup;
timefilter: TimefilterContract;
+ chartTheme: ChartsPluginSetup['theme'];
}
/** @internal */
@@ -43,6 +45,7 @@ export interface TimelionVisSetupDependencies {
expressions: ReturnType;
visualizations: VisualizationsSetup;
data: DataPublicPluginSetup;
+ charts: ChartsPluginSetup;
}
/** @internal */
@@ -72,13 +75,14 @@ export class TimelionVisPlugin
constructor(public initializerContext: PluginInitializerContext) {}
public setup(
- core: CoreSetup,
- { expressions, visualizations, data }: TimelionVisSetupDependencies
+ { uiSettings, http }: CoreSetup,
+ { expressions, visualizations, data, charts }: TimelionVisSetupDependencies
) {
const dependencies: TimelionVisDependencies = {
- uiSettings: core.uiSettings,
- http: core.http,
+ http,
+ uiSettings,
timefilter: data.query.timefilter.timefilter,
+ chartTheme: charts.theme,
};
expressions.registerFunction(() => getTimelionVisualizationConfig(dependencies));
diff --git a/src/plugins/vis_type_timelion/public/timelion_vis_renderer.tsx b/src/plugins/vis_type_timelion/public/timelion_vis_renderer.tsx
index 6ef5d29ea8a91..b14055a4d6b63 100644
--- a/src/plugins/vis_type_timelion/public/timelion_vis_renderer.tsx
+++ b/src/plugins/vis_type_timelion/public/timelion_vis_renderer.tsx
@@ -14,8 +14,11 @@ import { KibanaContextProvider } from '../../kibana_react/public';
import { VisualizationContainer } from '../../visualizations/public';
import { TimelionVisDependencies } from './plugin';
import { TimelionRenderValue } from './timelion_vis_fn';
-// @ts-ignore
+import { UI_SETTINGS } from '../common/constants';
+import { RangeFilterParams } from '../../data/public';
+
const TimelionVisComponent = lazy(() => import('./components/timelion_vis_component'));
+const TimelionVisLegacyComponent = lazy(() => import('./legacy/timelion_vis_component'));
export const getTimelionVisRenderer: (
deps: TimelionVisDependencies
@@ -31,14 +34,34 @@ export const getTimelionVisRenderer: (
const [seriesList] = visData.sheet;
const showNoResult = !seriesList || !seriesList.list.length;
+ const VisComponent = deps.uiSettings.get(UI_SETTINGS.LEGACY_CHARTS_LIBRARY, false)
+ ? TimelionVisLegacyComponent
+ : TimelionVisComponent;
+
+ const onBrushEvent = (rangeFilterParams: RangeFilterParams) => {
+ handlers.event({
+ name: 'applyFilter',
+ data: {
+ timeFieldName: '*',
+ filters: [
+ {
+ range: {
+ '*': rangeFilterParams,
+ },
+ },
+ ],
+ },
+ });
+ };
+
render(
-
,
diff --git a/src/plugins/vis_type_timelion/server/plugin.ts b/src/plugins/vis_type_timelion/server/plugin.ts
index c1800a09ba35c..fc23569b351e6 100644
--- a/src/plugins/vis_type_timelion/server/plugin.ts
+++ b/src/plugins/vis_type_timelion/server/plugin.ts
@@ -7,7 +7,7 @@
*/
import { i18n } from '@kbn/i18n';
-import { TypeOf, schema } from '@kbn/config-schema';
+import { TypeOf } from '@kbn/config-schema';
import { RecursiveReadonly } from '@kbn/utility-types';
import { deepFreeze } from '@kbn/std';
@@ -19,10 +19,7 @@ import { functionsRoute } from './routes/functions';
import { validateEsRoute } from './routes/validate_es';
import { runRoute } from './routes/run';
import { ConfigManager } from './lib/config_manager';
-
-const experimentalLabel = i18n.translate('timelion.uiSettings.experimentalLabel', {
- defaultMessage: 'experimental',
-});
+import { getUiSettings } from './ui_settings';
/**
* Describes public Timelion plugin contract returned at the `setup` stage.
@@ -78,97 +75,7 @@ export class TimelionPlugin
runRoute(router, deps);
validateEsRoute(router);
- core.uiSettings.register({
- 'timelion:es.timefield': {
- name: i18n.translate('timelion.uiSettings.timeFieldLabel', {
- defaultMessage: 'Time field',
- }),
- value: '@timestamp',
- description: i18n.translate('timelion.uiSettings.timeFieldDescription', {
- defaultMessage: 'Default field containing a timestamp when using {esParam}',
- values: { esParam: '.es()' },
- }),
- category: ['timelion'],
- schema: schema.string(),
- },
- 'timelion:es.default_index': {
- name: i18n.translate('timelion.uiSettings.defaultIndexLabel', {
- defaultMessage: 'Default index',
- }),
- value: '_all',
- description: i18n.translate('timelion.uiSettings.defaultIndexDescription', {
- defaultMessage: 'Default elasticsearch index to search with {esParam}',
- values: { esParam: '.es()' },
- }),
- category: ['timelion'],
- schema: schema.string(),
- },
- 'timelion:target_buckets': {
- name: i18n.translate('timelion.uiSettings.targetBucketsLabel', {
- defaultMessage: 'Target buckets',
- }),
- value: 200,
- description: i18n.translate('timelion.uiSettings.targetBucketsDescription', {
- defaultMessage: 'The number of buckets to shoot for when using auto intervals',
- }),
- category: ['timelion'],
- schema: schema.number(),
- },
- 'timelion:max_buckets': {
- name: i18n.translate('timelion.uiSettings.maximumBucketsLabel', {
- defaultMessage: 'Maximum buckets',
- }),
- value: 2000,
- description: i18n.translate('timelion.uiSettings.maximumBucketsDescription', {
- defaultMessage: 'The maximum number of buckets a single datasource can return',
- }),
- category: ['timelion'],
- schema: schema.number(),
- },
- 'timelion:min_interval': {
- name: i18n.translate('timelion.uiSettings.minimumIntervalLabel', {
- defaultMessage: 'Minimum interval',
- }),
- value: '1ms',
- description: i18n.translate('timelion.uiSettings.minimumIntervalDescription', {
- defaultMessage: 'The smallest interval that will be calculated when using "auto"',
- description:
- '"auto" is a technical value in that context, that should not be translated.',
- }),
- category: ['timelion'],
- schema: schema.string(),
- },
- 'timelion:graphite.url': {
- name: i18n.translate('timelion.uiSettings.graphiteURLLabel', {
- defaultMessage: 'Graphite URL',
- description:
- 'The URL should be in the form of https://www.hostedgraphite.com/UID/ACCESS_KEY/graphite',
- }),
- value: config.graphiteUrls && config.graphiteUrls.length ? config.graphiteUrls[0] : null,
- description: i18n.translate('timelion.uiSettings.graphiteURLDescription', {
- defaultMessage:
- '{experimentalLabel} The URL of your graphite host',
- values: { experimentalLabel: `[${experimentalLabel}]` },
- }),
- type: 'select',
- options: config.graphiteUrls || [],
- category: ['timelion'],
- schema: schema.nullable(schema.string()),
- },
- 'timelion:quandl.key': {
- name: i18n.translate('timelion.uiSettings.quandlKeyLabel', {
- defaultMessage: 'Quandl key',
- }),
- value: 'someKeyHere',
- description: i18n.translate('timelion.uiSettings.quandlKeyDescription', {
- defaultMessage: '{experimentalLabel} Your API key from www.quandl.com',
- values: { experimentalLabel: `[${experimentalLabel}]` },
- }),
- sensitive: true,
- category: ['timelion'],
- schema: schema.string(),
- },
- });
+ core.uiSettings.register(getUiSettings(config));
return deepFreeze({ uiEnabled: config.ui.enabled });
}
diff --git a/src/plugins/vis_type_timelion/server/series_functions/label.js b/src/plugins/vis_type_timelion/server/series_functions/label.js
index d31320d3ad6e9..30a5c626251d1 100644
--- a/src/plugins/vis_type_timelion/server/series_functions/label.js
+++ b/src/plugins/vis_type_timelion/server/series_functions/label.js
@@ -44,7 +44,7 @@ export default new Chainable('label', {
// that it doesn't prevent Kibana from starting up and we only have an issue using Timelion labels
const RE2 = require('re2');
eachSeries.label = eachSeries.label.replace(new RE2(config.regex), config.label);
- } else {
+ } else if (config.label) {
eachSeries.label = config.label;
}
diff --git a/src/plugins/vis_type_timelion/server/ui_settings.ts b/src/plugins/vis_type_timelion/server/ui_settings.ts
new file mode 100644
index 0000000000000..1d8dc997a3f6a
--- /dev/null
+++ b/src/plugins/vis_type_timelion/server/ui_settings.ts
@@ -0,0 +1,130 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { schema, TypeOf } from '@kbn/config-schema';
+import type { UiSettingsParams } from 'kibana/server';
+
+import { UI_SETTINGS } from '../common/constants';
+import { configSchema } from '../config';
+
+const experimentalLabel = i18n.translate('timelion.uiSettings.experimentalLabel', {
+ defaultMessage: 'experimental',
+});
+
+export function getUiSettings(
+ config: TypeOf
+): Record> {
+ return {
+ [UI_SETTINGS.LEGACY_CHARTS_LIBRARY]: {
+ name: i18n.translate('timelion.uiSettings.legacyChartsLibraryLabel', {
+ defaultMessage: 'Timelion legacy charts library',
+ }),
+ description: i18n.translate('timelion.uiSettings.legacyChartsLibraryDescription', {
+ defaultMessage: 'Enables the legacy charts library for timelion charts in Visualize',
+ }),
+ deprecation: {
+ message: i18n.translate('timelion.uiSettings.legacyChartsLibraryDeprication', {
+ defaultMessage: 'This setting is deprecated and will not be supported as of 8.0.',
+ }),
+ docLinksKey: 'timelionSettings',
+ },
+ value: false,
+ category: ['timelion'],
+ schema: schema.boolean(),
+ },
+ [UI_SETTINGS.ES_TIMEFIELD]: {
+ name: i18n.translate('timelion.uiSettings.timeFieldLabel', {
+ defaultMessage: 'Time field',
+ }),
+ value: '@timestamp',
+ description: i18n.translate('timelion.uiSettings.timeFieldDescription', {
+ defaultMessage: 'Default field containing a timestamp when using {esParam}',
+ values: { esParam: '.es()' },
+ }),
+ category: ['timelion'],
+ schema: schema.string(),
+ },
+ [UI_SETTINGS.DEFAULT_INDEX]: {
+ name: i18n.translate('timelion.uiSettings.defaultIndexLabel', {
+ defaultMessage: 'Default index',
+ }),
+ value: '_all',
+ description: i18n.translate('timelion.uiSettings.defaultIndexDescription', {
+ defaultMessage: 'Default elasticsearch index to search with {esParam}',
+ values: { esParam: '.es()' },
+ }),
+ category: ['timelion'],
+ schema: schema.string(),
+ },
+ [UI_SETTINGS.TARGET_BUCKETS]: {
+ name: i18n.translate('timelion.uiSettings.targetBucketsLabel', {
+ defaultMessage: 'Target buckets',
+ }),
+ value: 200,
+ description: i18n.translate('timelion.uiSettings.targetBucketsDescription', {
+ defaultMessage: 'The number of buckets to shoot for when using auto intervals',
+ }),
+ category: ['timelion'],
+ schema: schema.number(),
+ },
+ [UI_SETTINGS.MAX_BUCKETS]: {
+ name: i18n.translate('timelion.uiSettings.maximumBucketsLabel', {
+ defaultMessage: 'Maximum buckets',
+ }),
+ value: 2000,
+ description: i18n.translate('timelion.uiSettings.maximumBucketsDescription', {
+ defaultMessage: 'The maximum number of buckets a single datasource can return',
+ }),
+ category: ['timelion'],
+ schema: schema.number(),
+ },
+ [UI_SETTINGS.MIN_INTERVAL]: {
+ name: i18n.translate('timelion.uiSettings.minimumIntervalLabel', {
+ defaultMessage: 'Minimum interval',
+ }),
+ value: '1ms',
+ description: i18n.translate('timelion.uiSettings.minimumIntervalDescription', {
+ defaultMessage: 'The smallest interval that will be calculated when using "auto"',
+ description: '"auto" is a technical value in that context, that should not be translated.',
+ }),
+ category: ['timelion'],
+ schema: schema.string(),
+ },
+ [UI_SETTINGS.GRAPHITE_URL]: {
+ name: i18n.translate('timelion.uiSettings.graphiteURLLabel', {
+ defaultMessage: 'Graphite URL',
+ description:
+ 'The URL should be in the form of https://www.hostedgraphite.com/UID/ACCESS_KEY/graphite',
+ }),
+ value: config.graphiteUrls && config.graphiteUrls.length ? config.graphiteUrls[0] : null,
+ description: i18n.translate('timelion.uiSettings.graphiteURLDescription', {
+ defaultMessage:
+ '{experimentalLabel} The URL of your graphite host',
+ values: { experimentalLabel: `[${experimentalLabel}]` },
+ }),
+ type: 'select',
+ options: config.graphiteUrls || [],
+ category: ['timelion'],
+ schema: schema.nullable(schema.string()),
+ },
+ [UI_SETTINGS.QUANDL_KEY]: {
+ name: i18n.translate('timelion.uiSettings.quandlKeyLabel', {
+ defaultMessage: 'Quandl key',
+ }),
+ value: 'someKeyHere',
+ description: i18n.translate('timelion.uiSettings.quandlKeyDescription', {
+ defaultMessage: '{experimentalLabel} Your API key from www.quandl.com',
+ values: { experimentalLabel: `[${experimentalLabel}]` },
+ }),
+ sensitive: true,
+ category: ['timelion'],
+ schema: schema.string(),
+ },
+ };
+}
diff --git a/src/plugins/vis_type_vislib/public/vislib/lib/binder.ts b/src/plugins/vis_type_vislib/public/vislib/lib/binder.ts
index 436a5b85a6887..886745ba19563 100644
--- a/src/plugins/vis_type_vislib/public/vislib/lib/binder.ts
+++ b/src/plugins/vis_type_vislib/public/vislib/lib/binder.ts
@@ -8,7 +8,6 @@
import d3 from 'd3';
import $ from 'jquery';
-import { IScope } from 'angular';
export interface Emitter {
on: (...args: any[]) => void;
@@ -20,13 +19,6 @@ export interface Emitter {
export class Binder {
private disposal: Array<() => void> = [];
- constructor($scope: IScope) {
- // support auto-binding to $scope objects
- if ($scope) {
- $scope.$on('$destroy', () => this.destroy());
- }
- }
-
public on(emitter: Emitter, ...args: any[]) {
const on = emitter.on || emitter.addListener;
const off = emitter.off || emitter.removeListener;
diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts
index 616c07dd8cf9c..8dc695abc8d45 100644
--- a/test/functional/apps/discover/_discover.ts
+++ b/test/functional/apps/discover/_discover.ts
@@ -108,7 +108,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should modify the time range when the histogram is brushed', async function () {
// this is the number of renderings of the histogram needed when new data is fetched
// this needs to be improved
- const renderingCountInc = 1;
+ const renderingCountInc = 2;
const prevRenderingCount = await elasticChart.getVisualizationRenderingCount();
await PageObjects.timePicker.setDefaultAbsoluteRange();
await PageObjects.discover.waitUntilSearchingHasFinished();
@@ -268,8 +268,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await kibanaServer.uiSettings.replace({ 'discover:searchOnPageLoad': true });
await PageObjects.common.navigateToApp('discover');
await PageObjects.header.awaitKibanaChrome();
-
- expect(await PageObjects.discover.getNrOfFetches()).to.be(1);
+ await retry.waitFor('number of fetches to be 1', async () => {
+ const nrOfFetches = await PageObjects.discover.getNrOfFetches();
+ return nrOfFetches === 1;
+ });
});
});
diff --git a/test/functional/apps/discover/_inspector.ts b/test/functional/apps/discover/_inspector.ts
index 17f358ec74871..9ff425be2739b 100644
--- a/test/functional/apps/discover/_inspector.ts
+++ b/test/functional/apps/discover/_inspector.ts
@@ -15,6 +15,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const inspector = getService('inspector');
+ const testSubjects = getService('testSubjects');
const STATS_ROW_NAME_INDEX = 0;
const STATS_ROW_VALUE_INDEX = 1;
@@ -50,15 +51,28 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should display request stats with no results', async () => {
await inspector.open();
- const requestStats = await inspector.getTableData();
-
- expect(getHitCount(requestStats)).to.be('0');
+ await testSubjects.click('inspectorRequestChooser');
+ let foundZero = false;
+ for (const subj of ['Documents', 'Total hits', 'Charts']) {
+ await testSubjects.click(`inspectorRequestChooser${subj}`);
+ if (testSubjects.exists('inspectorRequestDetailStatistics', { timeout: 500 })) {
+ await testSubjects.click(`inspectorRequestDetailStatistics`);
+ const requestStatsTotalHits = getHitCount(await inspector.getTableData());
+ if (requestStatsTotalHits === '0') {
+ foundZero = true;
+ break;
+ }
+ }
+ }
+ expect(foundZero).to.be(true);
});
it('should display request stats with results', async () => {
await PageObjects.timePicker.setDefaultAbsoluteRange();
-
await inspector.open();
+ await testSubjects.click('inspectorRequestChooser');
+ await testSubjects.click(`inspectorRequestChooserDocuments`);
+ await testSubjects.click(`inspectorRequestDetailStatistics`);
const requestStats = await inspector.getTableData();
expect(getHitCount(requestStats)).to.be('500');
diff --git a/test/functional/services/saved_query_management_component.ts b/test/functional/services/saved_query_management_component.ts
index decf1618c7879..37c6a45557f2f 100644
--- a/test/functional/services/saved_query_management_component.ts
+++ b/test/functional/services/saved_query_management_component.ts
@@ -138,7 +138,7 @@ export class SavedQueryManagementComponentService extends FtrService {
async savedQueryExist(title: string) {
await this.openSavedQueryManagementComponent();
- const exists = this.testSubjects.exists(`~load-saved-query-${title}-button`);
+ const exists = await this.testSubjects.exists(`~load-saved-query-${title}-button`);
await this.closeSavedQueryManagementComponent();
return exists;
}
diff --git a/x-pack/plugins/alerting/server/lib/license_state.mock.ts b/x-pack/plugins/alerting/server/lib/license_state.mock.ts
index 3932ce9329824..1521a1cf25da9 100644
--- a/x-pack/plugins/alerting/server/lib/license_state.mock.ts
+++ b/x-pack/plugins/alerting/server/lib/license_state.mock.ts
@@ -18,6 +18,7 @@ export const createLicenseStateMock = () => {
checkLicense: jest.fn().mockResolvedValue({
state: 'valid',
}),
+ getIsSecurityEnabled: jest.fn(),
setNotifyUsage: jest.fn(),
};
return licenseState;
diff --git a/x-pack/plugins/alerting/server/lib/license_state.test.ts b/x-pack/plugins/alerting/server/lib/license_state.test.ts
index 6cfe368245842..e20acafbab314 100644
--- a/x-pack/plugins/alerting/server/lib/license_state.test.ts
+++ b/x-pack/plugins/alerting/server/lib/license_state.test.ts
@@ -272,6 +272,42 @@ describe('ensureLicenseForAlertType()', () => {
});
});
+describe('getIsSecurityEnabled()', () => {
+ let license: Subject;
+ let licenseState: ILicenseState;
+ beforeEach(() => {
+ license = new Subject();
+ licenseState = new LicenseState(license);
+ });
+
+ test('should return null when license is not defined', () => {
+ expect(licenseState.getIsSecurityEnabled()).toBeNull();
+ });
+
+ test('should return null when license is unavailable', () => {
+ license.next(createUnavailableLicense());
+ expect(licenseState.getIsSecurityEnabled()).toBeNull();
+ });
+
+ test('should return true if security is enabled', () => {
+ const basicLicense = licensingMock.createLicense({
+ license: { status: 'active', type: 'basic' },
+ features: { security: { isEnabled: true, isAvailable: true } },
+ });
+ license.next(basicLicense);
+ expect(licenseState.getIsSecurityEnabled()).toEqual(true);
+ });
+
+ test('should return false if security is not enabled', () => {
+ const basicLicense = licensingMock.createLicense({
+ license: { status: 'active', type: 'basic' },
+ features: { security: { isEnabled: false, isAvailable: true } },
+ });
+ license.next(basicLicense);
+ expect(licenseState.getIsSecurityEnabled()).toEqual(false);
+ });
+});
+
function createUnavailableLicense() {
const unavailableLicense = licensingMock.createLicenseMock();
unavailableLicense.isAvailable = false;
diff --git a/x-pack/plugins/alerting/server/lib/license_state.ts b/x-pack/plugins/alerting/server/lib/license_state.ts
index 837fecde11659..9f6fd1b292af8 100644
--- a/x-pack/plugins/alerting/server/lib/license_state.ts
+++ b/x-pack/plugins/alerting/server/lib/license_state.ts
@@ -55,6 +55,15 @@ export class LicenseState {
return this.licenseInformation;
}
+ public getIsSecurityEnabled(): boolean | null {
+ if (!this.license || !this.license?.isAvailable) {
+ return null;
+ }
+
+ const { isEnabled } = this.license.getFeature('security');
+ return isEnabled;
+ }
+
public setNotifyUsage(notifyUsage: LicensingPluginStart['featureUsage']['notifyUsage']) {
this._notifyUsage = notifyUsage;
}
diff --git a/x-pack/plugins/alerting/server/routes/health.test.ts b/x-pack/plugins/alerting/server/routes/health.test.ts
index 7c00b04ae7ef3..b8e023e4f4d1b 100644
--- a/x-pack/plugins/alerting/server/routes/health.test.ts
+++ b/x-pack/plugins/alerting/server/routes/health.test.ts
@@ -21,7 +21,6 @@ jest.mock('../lib/license_api_access.ts', () => ({
}));
const alerting = alertsMock.createStart();
-
const currentDate = new Date().toISOString();
beforeEach(() => {
jest.resetAllMocks();
@@ -62,7 +61,15 @@ describe('healthRoute', () => {
healthRoute(router, licenseState, encryptedSavedObjects);
const [, handler] = router.get.mock.calls[0];
- const [context, req, res] = mockHandlerArguments({ rulesClient }, {}, ['ok']);
+ const [context, req, res] = mockHandlerArguments(
+ {
+ rulesClient,
+ getFrameworkHealth: alerting.getFrameworkHealth,
+ areApiKeysEnabled: () => Promise.resolve(true),
+ },
+ {},
+ ['ok']
+ );
await handler(context, req, res);
@@ -78,7 +85,11 @@ describe('healthRoute', () => {
const [, handler] = router.get.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
- { rulesClient, getFrameworkHealth: alerting.getFrameworkHealth },
+ {
+ rulesClient,
+ getFrameworkHealth: alerting.getFrameworkHealth,
+ areApiKeysEnabled: () => Promise.resolve(true),
+ },
{},
['ok']
);
@@ -105,52 +116,21 @@ describe('healthRoute', () => {
});
});
- it('evaluates missing security info from the usage api to mean that the security plugin is disbled', async () => {
+ test('when ES security status cannot be determined from license state, isSufficientlySecure should return false', async () => {
const router = httpServiceMock.createRouter();
-
const licenseState = licenseStateMock.create();
const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true });
- healthRoute(router, licenseState, encryptedSavedObjects);
- const [, handler] = router.get.mock.calls[0];
+ licenseState.getIsSecurityEnabled.mockReturnValueOnce(null);
- const [context, req, res] = mockHandlerArguments(
- { rulesClient, getFrameworkHealth: alerting.getFrameworkHealth },
- {},
- ['ok']
- );
-
- expect(await handler(context, req, res)).toStrictEqual({
- body: {
- alerting_framework_heath: {
- decryption_health: {
- status: HealthStatus.OK,
- timestamp: currentDate,
- },
- execution_health: {
- status: HealthStatus.OK,
- timestamp: currentDate,
- },
- read_health: {
- status: HealthStatus.OK,
- timestamp: currentDate,
- },
- },
- has_permanent_encryption_key: true,
- is_sufficiently_secure: true,
- },
- });
- });
-
- it('evaluates missing security http info from the usage api to mean that the security plugin is disbled', async () => {
- const router = httpServiceMock.createRouter();
-
- const licenseState = licenseStateMock.create();
- const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true });
healthRoute(router, licenseState, encryptedSavedObjects);
const [, handler] = router.get.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
- { rulesClient, getFrameworkHealth: alerting.getFrameworkHealth },
+ {
+ rulesClient,
+ getFrameworkHealth: alerting.getFrameworkHealth,
+ areApiKeysEnabled: () => Promise.resolve(true),
+ },
{},
['ok']
);
@@ -172,16 +152,17 @@ describe('healthRoute', () => {
},
},
has_permanent_encryption_key: true,
- is_sufficiently_secure: true,
+ is_sufficiently_secure: false,
},
});
});
- it('evaluates security enabled, and missing ssl info from the usage api to mean that the user cannot generate keys', async () => {
+ test('when ES security is disabled, isSufficientlySecure should return true', async () => {
const router = httpServiceMock.createRouter();
-
const licenseState = licenseStateMock.create();
const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true });
+ licenseState.getIsSecurityEnabled.mockReturnValueOnce(false);
+
healthRoute(router, licenseState, encryptedSavedObjects);
const [, handler] = router.get.mock.calls[0];
@@ -212,16 +193,17 @@ describe('healthRoute', () => {
},
},
has_permanent_encryption_key: true,
- is_sufficiently_secure: false,
+ is_sufficiently_secure: true,
},
});
});
- it('evaluates security enabled, SSL info present but missing http info from the usage api to mean that the user cannot generate keys', async () => {
+ test('when ES security is enabled but user cannot generate api keys, isSufficientlySecure should return false', async () => {
const router = httpServiceMock.createRouter();
-
const licenseState = licenseStateMock.create();
const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true });
+ licenseState.getIsSecurityEnabled.mockReturnValueOnce(true);
+
healthRoute(router, licenseState, encryptedSavedObjects);
const [, handler] = router.get.mock.calls[0];
@@ -257,16 +239,21 @@ describe('healthRoute', () => {
});
});
- it('evaluates security and tls enabled to mean that the user can generate keys', async () => {
+ test('when ES security is enabled and user can generate api keys, isSufficientlySecure should return true', async () => {
const router = httpServiceMock.createRouter();
-
const licenseState = licenseStateMock.create();
const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true });
+ licenseState.getIsSecurityEnabled.mockReturnValueOnce(true);
+
healthRoute(router, licenseState, encryptedSavedObjects);
const [, handler] = router.get.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
- { rulesClient, getFrameworkHealth: alerting.getFrameworkHealth },
+ {
+ rulesClient,
+ getFrameworkHealth: alerting.getFrameworkHealth,
+ areApiKeysEnabled: () => Promise.resolve(true),
+ },
{},
['ok']
);
diff --git a/x-pack/plugins/alerting/server/routes/health.ts b/x-pack/plugins/alerting/server/routes/health.ts
index 96016ccc45472..fa09213dada3a 100644
--- a/x-pack/plugins/alerting/server/routes/health.ts
+++ b/x-pack/plugins/alerting/server/routes/health.ts
@@ -44,11 +44,22 @@ export const healthRoute = (
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
try {
+ const isEsSecurityEnabled: boolean | null = licenseState.getIsSecurityEnabled();
const areApiKeysEnabled = await context.alerting.areApiKeysEnabled();
const alertingFrameworkHeath = await context.alerting.getFrameworkHealth();
+ let isSufficientlySecure;
+ if (isEsSecurityEnabled === null) {
+ isSufficientlySecure = false;
+ } else {
+ // if isEsSecurityEnabled = true, then areApiKeysEnabled must be true to enable alerting
+ // if isEsSecurityEnabled = false, then it does not matter what areApiKeysEnabled is
+ isSufficientlySecure =
+ !isEsSecurityEnabled || (isEsSecurityEnabled && areApiKeysEnabled);
+ }
+
const frameworkHealth: AlertingFrameworkHealth = {
- isSufficientlySecure: areApiKeysEnabled,
+ isSufficientlySecure,
hasPermanentEncryptionKey: encryptedSavedObjects.canEncrypt,
alertingFrameworkHeath,
};
diff --git a/x-pack/plugins/alerting/server/routes/legacy/health.test.ts b/x-pack/plugins/alerting/server/routes/legacy/health.test.ts
index 57bfdc29024f6..ed8e6763d66b7 100644
--- a/x-pack/plugins/alerting/server/routes/legacy/health.test.ts
+++ b/x-pack/plugins/alerting/server/routes/legacy/health.test.ts
@@ -62,7 +62,11 @@ describe('healthRoute', () => {
const [, handler] = router.get.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
- { rulesClient, getFrameworkHealth: alerting.getFrameworkHealth },
+ {
+ rulesClient,
+ getFrameworkHealth: alerting.getFrameworkHealth,
+ areApiKeysEnabled: () => Promise.resolve(true),
+ },
{},
['ok']
);
@@ -89,52 +93,21 @@ describe('healthRoute', () => {
});
});
- it('evaluates missing security info from the usage api to mean that the security plugin is disbled', async () => {
+ test('when ES security status cannot be determined from license state, isSufficientlySecure should return false', async () => {
const router = httpServiceMock.createRouter();
-
const licenseState = licenseStateMock.create();
const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true });
- healthRoute(router, licenseState, encryptedSavedObjects);
- const [, handler] = router.get.mock.calls[0];
-
- const [context, req, res] = mockHandlerArguments(
- { rulesClient, getFrameworkHealth: alerting.getFrameworkHealth },
- {},
- ['ok']
- );
-
- expect(await handler(context, req, res)).toStrictEqual({
- body: {
- alertingFrameworkHeath: {
- decryptionHealth: {
- status: HealthStatus.OK,
- timestamp: currentDate,
- },
- executionHealth: {
- status: HealthStatus.OK,
- timestamp: currentDate,
- },
- readHealth: {
- status: HealthStatus.OK,
- timestamp: currentDate,
- },
- },
- hasPermanentEncryptionKey: true,
- isSufficientlySecure: true,
- },
- });
- });
-
- it('evaluates missing security http info from the usage api to mean that the security plugin is disbled', async () => {
- const router = httpServiceMock.createRouter();
+ licenseState.getIsSecurityEnabled.mockReturnValueOnce(null);
- const licenseState = licenseStateMock.create();
- const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true });
healthRoute(router, licenseState, encryptedSavedObjects);
const [, handler] = router.get.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
- { rulesClient, getFrameworkHealth: alerting.getFrameworkHealth },
+ {
+ rulesClient,
+ getFrameworkHealth: alerting.getFrameworkHealth,
+ areApiKeysEnabled: () => Promise.resolve(true),
+ },
{},
['ok']
);
@@ -156,16 +129,17 @@ describe('healthRoute', () => {
},
},
hasPermanentEncryptionKey: true,
- isSufficientlySecure: true,
+ isSufficientlySecure: false,
},
});
});
- it('evaluates security enabled, and missing ssl info from the usage api to mean that the user cannot generate keys', async () => {
+ test('when ES security is disabled, isSufficientlySecure should return true', async () => {
const router = httpServiceMock.createRouter();
-
const licenseState = licenseStateMock.create();
const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true });
+ licenseState.getIsSecurityEnabled.mockReturnValueOnce(false);
+
healthRoute(router, licenseState, encryptedSavedObjects);
const [, handler] = router.get.mock.calls[0];
@@ -196,16 +170,17 @@ describe('healthRoute', () => {
},
},
hasPermanentEncryptionKey: true,
- isSufficientlySecure: false,
+ isSufficientlySecure: true,
},
});
});
- it('evaluates security enabled, SSL info present but missing http info from the usage api to mean that the user cannot generate keys', async () => {
+ test('when ES security is enabled but user cannot generate api keys, isSufficientlySecure should return false', async () => {
const router = httpServiceMock.createRouter();
-
const licenseState = licenseStateMock.create();
const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true });
+ licenseState.getIsSecurityEnabled.mockReturnValueOnce(true);
+
healthRoute(router, licenseState, encryptedSavedObjects);
const [, handler] = router.get.mock.calls[0];
@@ -241,16 +216,21 @@ describe('healthRoute', () => {
});
});
- it('evaluates security and tls enabled to mean that the user can generate keys', async () => {
+ test('when ES security is enabled and user can generate api keys, isSufficientlySecure should return true', async () => {
const router = httpServiceMock.createRouter();
-
const licenseState = licenseStateMock.create();
const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true });
+ licenseState.getIsSecurityEnabled.mockReturnValueOnce(true);
+
healthRoute(router, licenseState, encryptedSavedObjects);
const [, handler] = router.get.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
- { rulesClient, getFrameworkHealth: alerting.getFrameworkHealth },
+ {
+ rulesClient,
+ getFrameworkHealth: alerting.getFrameworkHealth,
+ areApiKeysEnabled: () => Promise.resolve(true),
+ },
{},
['ok']
);
diff --git a/x-pack/plugins/alerting/server/routes/legacy/health.ts b/x-pack/plugins/alerting/server/routes/legacy/health.ts
index 206a74c2ea636..03a574ca62c33 100644
--- a/x-pack/plugins/alerting/server/routes/legacy/health.ts
+++ b/x-pack/plugins/alerting/server/routes/legacy/health.ts
@@ -27,11 +27,21 @@ export function healthRoute(
return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' });
}
try {
+ const isEsSecurityEnabled: boolean | null = licenseState.getIsSecurityEnabled();
const alertingFrameworkHeath = await context.alerting.getFrameworkHealth();
const areApiKeysEnabled = await context.alerting.areApiKeysEnabled();
+ let isSufficientlySecure;
+ if (isEsSecurityEnabled === null) {
+ isSufficientlySecure = false;
+ } else {
+ // if isEsSecurityEnabled = true, then areApiKeysEnabled must be true to enable alerting
+ // if isEsSecurityEnabled = false, then it does not matter what areApiKeysEnabled is
+ isSufficientlySecure = !isEsSecurityEnabled || (isEsSecurityEnabled && areApiKeysEnabled);
+ }
+
const frameworkHealth: AlertingFrameworkHealth = {
- isSufficientlySecure: areApiKeysEnabled,
+ isSufficientlySecure,
hasPermanentEncryptionKey: encryptedSavedObjects.canEncrypt,
alertingFrameworkHeath,
};
diff --git a/x-pack/plugins/apm/public/components/shared/kuery_bar/index.tsx b/x-pack/plugins/apm/public/components/shared/kuery_bar/index.tsx
index 72c5bac1f9f17..8358837cac563 100644
--- a/x-pack/plugins/apm/public/components/shared/kuery_bar/index.tsx
+++ b/x-pack/plugins/apm/public/components/shared/kuery_bar/index.tsx
@@ -11,7 +11,7 @@ import React, { useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import {
esKuery,
- IIndexPattern,
+ IndexPattern,
QuerySuggestion,
} from '../../../../../../../src/plugins/data/public';
import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context';
@@ -29,7 +29,7 @@ interface State {
isLoadingSuggestions: boolean;
}
-function convertKueryToEsQuery(kuery: string, indexPattern: IIndexPattern) {
+function convertKueryToEsQuery(kuery: string, indexPattern: IndexPattern) {
const ast = esKuery.fromKueryExpression(kuery);
return esKuery.toElasticsearchQuery(ast, indexPattern);
}
@@ -125,7 +125,10 @@ export function KueryBar(props: { prepend?: React.ReactNode | string }) {
}
try {
- const res = convertKueryToEsQuery(inputValue, indexPattern);
+ const res = convertKueryToEsQuery(
+ inputValue,
+ indexPattern as IndexPattern
+ );
if (!res) {
return;
}
diff --git a/x-pack/plugins/lens/common/suffix_formatter/index.ts b/x-pack/plugins/lens/common/suffix_formatter/index.ts
index 12a4e02a81ef2..97fa8c067331e 100644
--- a/x-pack/plugins/lens/common/suffix_formatter/index.ts
+++ b/x-pack/plugins/lens/common/suffix_formatter/index.ts
@@ -31,6 +31,7 @@ export const unitSuffixesLong: Record = {
export function getSuffixFormatter(formatFactory: FormatFactory): FieldFormatInstanceType {
return class SuffixFormatter extends FieldFormat {
static id = 'suffix';
+ static hidden = true; // Don't want this format to appear in index pattern editor
static title = i18n.translate('xpack.lens.fieldFormats.suffix.title', {
defaultMessage: 'Suffix',
});
diff --git a/x-pack/plugins/lens/common/suffix_formatter/suffix_formatter.test.ts b/x-pack/plugins/lens/common/suffix_formatter/suffix_formatter.test.ts
index c4379bdd1fb34..d08908ecde417 100644
--- a/x-pack/plugins/lens/common/suffix_formatter/suffix_formatter.test.ts
+++ b/x-pack/plugins/lens/common/suffix_formatter/suffix_formatter.test.ts
@@ -42,4 +42,11 @@ describe('suffix formatter', () => {
expect(result).toEqual('');
});
+
+ it('should be a hidden formatter', () => {
+ const convertMock = jest.fn((x) => '');
+ const formatFactory = jest.fn(() => ({ convert: convertMock }));
+ const SuffixFormatter = getSuffixFormatter((formatFactory as unknown) as FormatFactory);
+ expect(SuffixFormatter.hidden).toBe(true);
+ });
});
diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx
index 1c49527d9eca8..6bd75c585f954 100644
--- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx
+++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx
@@ -30,7 +30,7 @@ import {
esFilters,
FilterManager,
IFieldType,
- IIndexPattern,
+ IndexPattern,
Query,
} from '../../../../../src/plugins/data/public';
import { TopNavMenuData } from '../../../../../src/plugins/navigation/public';
@@ -182,7 +182,7 @@ describe('Lens App', () => {
it('updates global filters with store state', async () => {
const services = makeDefaultServices(sessionIdSubject);
- const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern;
+ const indexPattern = ({ id: 'index1' } as unknown) as IndexPattern;
const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType;
const pinnedFilter = esFilters.buildExistsFilter(pinnedField, indexPattern);
services.data.query.filterManager.getFilters = jest.fn().mockImplementation(() => {
@@ -634,7 +634,7 @@ describe('Lens App', () => {
});
it('saves app filters and does not save pinned filters', async () => {
- const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern;
+ const indexPattern = ({ id: 'index1' } as unknown) as IndexPattern;
const field = ({ name: 'myfield' } as unknown) as IFieldType;
const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType;
const unpinned = esFilters.buildExistsFilter(field, indexPattern);
@@ -816,7 +816,7 @@ describe('Lens App', () => {
it('updates the filters when the user changes them', async () => {
const { instance, services, lensStore } = await mountWith({});
- const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern;
+ const indexPattern = ({ id: 'index1' } as unknown) as IndexPattern;
const field = ({ name: 'myfield' } as unknown) as IFieldType;
expect(lensStore.getState()).toEqual({
lens: expect.objectContaining({
@@ -871,7 +871,7 @@ describe('Lens App', () => {
searchSessionId: `sessionId-3`,
}),
});
- const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern;
+ const indexPattern = ({ id: 'index1' } as unknown) as IndexPattern;
const field = ({ name: 'myfield' } as unknown) as IFieldType;
act(() =>
services.data.query.filterManager.setFilters([
@@ -1006,7 +1006,7 @@ describe('Lens App', () => {
query: { query: 'new', language: 'lucene' },
})
);
- const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern;
+ const indexPattern = ({ id: 'index1' } as unknown) as IndexPattern;
const field = ({ name: 'myfield' } as unknown) as IFieldType;
const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType;
const unpinned = esFilters.buildExistsFilter(field, indexPattern);
@@ -1063,7 +1063,7 @@ describe('Lens App', () => {
query: { query: 'new', language: 'lucene' },
})
);
- const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern;
+ const indexPattern = ({ id: 'index1' } as unknown) as IndexPattern;
const field = ({ name: 'myfield' } as unknown) as IFieldType;
const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType;
const unpinned = esFilters.buildExistsFilter(field, indexPattern);
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx
index c7147e75af59a..d4a9870056b34 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx
@@ -199,7 +199,7 @@ export function LayerPanels(
})}
content={i18n.translate('xpack.lens.xyChart.addLayerTooltip', {
defaultMessage:
- 'Use multiple layers to combine chart types or visualize different index patterns.',
+ 'Use multiple layers to combine visualization types or visualize different index patterns.',
})}
position="bottom"
>
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx
index 6445038e40d7c..44fb47001631e 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx
@@ -16,7 +16,7 @@ import {
} from '../../mocks';
import { act } from 'react-dom/test-utils';
import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public';
-import { esFilters, IFieldType, IIndexPattern } from '../../../../../../src/plugins/data/public';
+import { esFilters, IFieldType, IndexPattern } from '../../../../../../src/plugins/data/public';
import { SuggestionPanel, SuggestionPanelProps } from './suggestion_panel';
import { getSuggestions, Suggestion } from './suggestion_helpers';
import { EuiIcon, EuiPanel, EuiToolTip } from '@elastic/eui';
@@ -291,7 +291,7 @@ describe('suggestion_panel', () => {
(mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce('test | expression');
mockDatasource.toExpression.mockReturnValue('datasource_expression');
- const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern;
+ const indexPattern = ({ id: 'index1' } as unknown) as IndexPattern;
const field = ({ name: 'myfield' } as unknown) as IFieldType;
mountWithProvider(
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx
index f948ec6a59687..314989ecc9758 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx
@@ -370,7 +370,7 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) {
'xpack.lens.chartSwitch.dataLossDescription',
{
defaultMessage:
- 'Selecting this chart type will result in a partial loss of currently applied configuration selections.',
+ 'Selecting this visualization type will result in a partial loss of currently applied configuration selections.',
}
)}
iconProps={{
@@ -439,8 +439,8 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) {
- {i18n.translate('xpack.lens.configPanel.chartType', {
- defaultMessage: 'Chart type',
+ {i18n.translate('xpack.lens.configPanel.visualizationType', {
+ defaultMessage: 'Visualization type',
})}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx
index 4feb13fcfffd9..784455cc9f6d1 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx
@@ -28,7 +28,7 @@ import { ReactWrapper } from 'enzyme';
import { DragDrop, ChildDragDropProvider } from '../../../drag_drop';
import { fromExpression } from '@kbn/interpreter/common';
import { coreMock } from 'src/core/public/mocks';
-import { esFilters, IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/public';
+import { esFilters, IFieldType, IndexPattern } from '../../../../../../../src/plugins/data/public';
import { UiActionsStart } from '../../../../../../../src/plugins/ui_actions/public';
import { uiActionsPluginMock } from '../../../../../../../src/plugins/ui_actions/public/mocks';
import { TriggerContract } from '../../../../../../../src/plugins/ui_actions/public/triggers';
@@ -443,7 +443,7 @@ describe('workspace_panel', () => {
expect(expressionRendererMock).toHaveBeenCalledTimes(1);
- const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern;
+ const indexPattern = ({ id: 'index1' } as unknown) as IndexPattern;
const field = ({ name: 'myfield' } as unknown) as IFieldType;
await act(async () => {
diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx
index 77b2b06389240..e26466be6f81b 100644
--- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx
+++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx
@@ -12,7 +12,6 @@ import type { ExecutionContextServiceStart } from 'src/core/public';
import {
ExecutionContextSearch,
Filter,
- IIndexPattern,
Query,
TimefilterContract,
TimeRange,
@@ -83,7 +82,7 @@ export type LensByReferenceInput = SavedObjectEmbeddableInput & LensBaseEmbeddab
export type LensEmbeddableInput = LensByValueInput | LensByReferenceInput;
export interface LensEmbeddableOutput extends EmbeddableOutput {
- indexPatterns?: IIndexPattern[];
+ indexPatterns?: IndexPattern[];
}
export interface LensEmbeddableDeps {
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx
index 0a41e7e65212a..e643ea12528ee 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx
@@ -70,7 +70,7 @@ export function ChangeIndexPattern({
{i18n.translate('xpack.lens.indexPattern.changeIndexPatternTitle', {
- defaultMessage: 'Change index pattern',
+ defaultMessage: 'Index pattern',
})}
= {
// Wrapper around esQuery.buildEsQuery, handling errors (e.g. because a query can't be parsed) by
// returning a query dsl object not matching anything
function buildSafeEsQuery(
- indexPattern: IIndexPattern,
+ indexPattern: IndexPattern,
query: Query,
filters: Filter[],
queryConfig: EsQueryConfig
@@ -164,7 +164,7 @@ export function IndexPatternDataPanel({
}));
const dslQuery = buildSafeEsQuery(
- indexPatterns[currentIndexPatternId] as IIndexPattern,
+ indexPatterns[currentIndexPatternId],
query,
filters,
esQuery.getEsQueryConfig(core.uiSettings)
@@ -269,7 +269,7 @@ const defaultFieldGroups: {
};
const fieldFiltersLabel = i18n.translate('xpack.lens.indexPatterns.fieldFiltersLabel', {
- defaultMessage: 'Field filters',
+ defaultMessage: 'Filter by type',
});
const htmlId = htmlIdGenerator('datapanel');
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx
index 013bb46500d0d..5ceb452038426 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx
@@ -44,7 +44,6 @@ import {
ES_FIELD_TYPES,
Filter,
esQuery,
- IIndexPattern,
} from '../../../../../src/plugins/data/public';
import { FieldButton } from '../../../../../src/plugins/kibana_react/public';
import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
@@ -169,7 +168,7 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
.post(`/api/lens/index_stats/${indexPattern.id}/field`, {
body: JSON.stringify({
dslQuery: esQuery.buildEsQuery(
- indexPattern as IIndexPattern,
+ indexPattern,
query,
filters,
esQuery.getEsQueryConfig(core.uiSettings)
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx
index a458a1edcfa16..4e2f69c927a18 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx
@@ -19,11 +19,7 @@ import {
import { uniq } from 'lodash';
import { CoreStart } from 'kibana/public';
import { FieldStatsResponse } from '../../../../../common';
-import {
- AggFunctionsMapping,
- esQuery,
- IIndexPattern,
-} from '../../../../../../../../src/plugins/data/public';
+import { AggFunctionsMapping, esQuery } from '../../../../../../../../src/plugins/data/public';
import { buildExpressionFunction } from '../../../../../../../../src/plugins/expressions/public';
import { updateColumnParam, isReferenced } from '../../layer_helpers';
import { DataType, FramePublicAPI } from '../../../../types';
@@ -99,7 +95,7 @@ function getDisallowedTermsMessage(
body: JSON.stringify({
fieldName,
dslQuery: esQuery.buildEsQuery(
- indexPattern as IIndexPattern,
+ indexPattern,
frame.query,
frame.filters,
esQuery.getEsQueryConfig(core.uiSettings)
diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx
index ac0aa6cd4b1f1..d25726951ea8f 100644
--- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx
+++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx
@@ -250,7 +250,7 @@ export function PieComponent(
1
? esFilters.buildPhrasesFilter(indexField, value, indexPattern)
- : esFilters.buildPhraseFilter(indexField, value, indexPattern);
+ : esFilters.buildPhraseFilter(indexField, value as string, indexPattern);
filter.meta.type = value instanceof Array && value.length > 1 ? 'phrases' : 'phrase';
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx
index 5347ee875181b..83006f09a14be 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx
@@ -823,7 +823,8 @@ describe('Exception helpers', () => {
},
]);
});
-
+ });
+ describe('ransomware protection exception items', () => {
test('it should return pre-populated ransomware items for event code `ransomware`', () => {
const defaultItems = defaultEndpointExceptionItems('list_id', 'my_rule', {
_id: '123',
@@ -938,7 +939,9 @@ describe('Exception helpers', () => {
},
]);
});
+ });
+ describe('memory protection exception items', () => {
test('it should return pre-populated memory signature items for event code `memory_signature`', () => {
const defaultItems = defaultEndpointExceptionItems('list_id', 'my_rule', {
_id: '123',
@@ -990,6 +993,44 @@ describe('Exception helpers', () => {
]);
});
+ test('it should return pre-populated memory signature items for event code `memory_signature` and skip Empty', () => {
+ const defaultItems = defaultEndpointExceptionItems('list_id', 'my_rule', {
+ _id: '123',
+ process: {
+ name: '', // name is empty
+ // executable: '', left intentionally commented
+ hash: {
+ sha256: 'some hash',
+ },
+ },
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ Memory_protection: {
+ feature: 'signature',
+ },
+ event: {
+ code: 'memory_signature',
+ },
+ });
+
+ // should not contain name or executable
+ expect(defaultItems[0].entries).toEqual([
+ {
+ field: 'Memory_protection.feature',
+ operator: 'included',
+ type: 'match',
+ value: 'signature',
+ id: '123',
+ },
+ {
+ field: 'process.hash.sha256',
+ operator: 'included',
+ type: 'match',
+ value: 'some hash',
+ id: '123',
+ },
+ ]);
+ });
+
test('it should return pre-populated memory shellcode items for event code `malicious_thread`', () => {
const defaultItems = defaultEndpointExceptionItems('list_id', 'my_rule', {
_id: '123',
@@ -1085,7 +1126,115 @@ describe('Exception helpers', () => {
value: '4000',
id: '123',
},
- { field: 'region_size', operator: 'included', type: 'match', value: '4000', id: '123' },
+ {
+ field: 'region_size',
+ operator: 'included',
+ type: 'match',
+ value: '4000',
+ id: '123',
+ },
+ {
+ field: 'region_protection',
+ operator: 'included',
+ type: 'match',
+ value: 'RWX',
+ id: '123',
+ },
+ {
+ field: 'memory_pe.imphash',
+ operator: 'included',
+ type: 'match',
+ value: 'a hash',
+ id: '123',
+ },
+ ],
+ id: '123',
+ },
+ ]);
+ });
+
+ test('it should return pre-populated memory shellcode items for event code `malicious_thread` and skip empty', () => {
+ const defaultItems = defaultEndpointExceptionItems('list_id', 'my_rule', {
+ _id: '123',
+ process: {
+ name: '', // name is empty
+ // executable: '', left intentionally commented
+ Ext: {
+ token: {
+ integrity_level_name: 'high',
+ },
+ },
+ },
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ Memory_protection: {
+ feature: 'shellcode_thread',
+ self_injection: true,
+ },
+ event: {
+ code: 'malicious_thread',
+ },
+ Target: {
+ process: {
+ thread: {
+ Ext: {
+ start_address_allocation_offset: 0,
+ start_address_bytes_disasm_hash: 'a disam hash',
+ start_address_details: {
+ // allocation_type: '', left intentionally commented
+ allocation_size: 4000,
+ region_size: 4000,
+ region_protection: 'RWX',
+ memory_pe: {
+ imphash: 'a hash',
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ // no name, no exceutable, no allocation_type
+ expect(defaultItems[0].entries).toEqual([
+ {
+ field: 'Memory_protection.feature',
+ operator: 'included',
+ type: 'match',
+ value: 'shellcode_thread',
+ id: '123',
+ },
+ {
+ field: 'Memory_protection.self_injection',
+ operator: 'included',
+ type: 'match',
+ value: 'true',
+ id: '123',
+ },
+ {
+ field: 'process.Ext.token.integrity_level_name',
+ operator: 'included',
+ type: 'match',
+ value: 'high',
+ id: '123',
+ },
+ {
+ field: 'Target.process.thread.Ext.start_address_details',
+ type: 'nested',
+ entries: [
+ {
+ field: 'allocation_size',
+ operator: 'included',
+ type: 'match',
+ value: '4000',
+ id: '123',
+ },
+ {
+ field: 'region_size',
+ operator: 'included',
+ type: 'match',
+ value: '4000',
+ id: '123',
+ },
{
field: 'region_protection',
operator: 'included',
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx
index 613d295545461..62250a0933ffb 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx
@@ -343,6 +343,29 @@ export const getCodeSignatureValue = (
}
};
+// helper type to filter empty-valued exception entries
+interface ExceptionEntry {
+ value?: string;
+ entries?: ExceptionEntry[];
+}
+
+/**
+ * Takes an array of Entries and filter out the ones with empty values.
+ * It will also filter out empty values for nested entries.
+ */
+function filterEmptyExceptionEntries(entries: T[]): T[] {
+ const finalEntries: T[] = [];
+ for (const entry of entries) {
+ if (entry.entries !== undefined) {
+ entry.entries = entry.entries.filter((el) => el.value !== undefined && el.value.length > 0);
+ finalEntries.push(entry);
+ } else if (entry.value !== undefined && entry.value.length > 0) {
+ finalEntries.push(entry);
+ }
+ }
+ return finalEntries;
+}
+
/**
* Returns the default values from the alert data to autofill new endpoint exceptions
*/
@@ -510,34 +533,35 @@ export const getPrepopulatedMemorySignatureException = ({
alertEcsData: Flattened;
}): ExceptionsBuilderExceptionItem => {
const { process } = alertEcsData;
+ const entries = filterEmptyExceptionEntries([
+ {
+ field: 'Memory_protection.feature',
+ operator: 'included' as const,
+ type: 'match' as const,
+ value: alertEcsData.Memory_protection?.feature ?? '',
+ },
+ {
+ field: 'process.executable.caseless',
+ operator: 'included' as const,
+ type: 'match' as const,
+ value: process?.executable ?? '',
+ },
+ {
+ field: 'process.name.caseless',
+ operator: 'included' as const,
+ type: 'match' as const,
+ value: process?.name ?? '',
+ },
+ {
+ field: 'process.hash.sha256',
+ operator: 'included' as const,
+ type: 'match' as const,
+ value: process?.hash?.sha256 ?? '',
+ },
+ ]);
return {
...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }),
- entries: addIdToEntries([
- {
- field: 'Memory_protection.feature',
- operator: 'included',
- type: 'match',
- value: alertEcsData.Memory_protection?.feature ?? '',
- },
- {
- field: 'process.executable.caseless',
- operator: 'included',
- type: 'match',
- value: process?.executable ?? '',
- },
- {
- field: 'process.name.caseless',
- operator: 'included',
- type: 'match',
- value: process?.name ?? '',
- },
- {
- field: 'process.hash.sha256',
- operator: 'included',
- type: 'match',
- value: process?.hash?.sha256 ?? '',
- },
- ]),
+ entries: addIdToEntries(entries),
};
};
export const getPrepopulatedMemoryShellcodeException = ({
@@ -554,81 +578,83 @@ export const getPrepopulatedMemoryShellcodeException = ({
alertEcsData: Flattened;
}): ExceptionsBuilderExceptionItem => {
const { process, Target } = alertEcsData;
+ const entries = filterEmptyExceptionEntries([
+ {
+ field: 'Memory_protection.feature',
+ operator: 'included' as const,
+ type: 'match' as const,
+ value: alertEcsData.Memory_protection?.feature ?? '',
+ },
+ {
+ field: 'Memory_protection.self_injection',
+ operator: 'included' as const,
+ type: 'match' as const,
+ value: String(alertEcsData.Memory_protection?.self_injection) ?? '',
+ },
+ {
+ field: 'process.executable.caseless',
+ operator: 'included' as const,
+ type: 'match' as const,
+ value: process?.executable ?? '',
+ },
+ {
+ field: 'process.name.caseless',
+ operator: 'included' as const,
+ type: 'match' as const,
+ value: process?.name ?? '',
+ },
+ {
+ field: 'process.Ext.token.integrity_level_name',
+ operator: 'included' as const,
+ type: 'match' as const,
+ value: process?.Ext?.token?.integrity_level_name ?? '',
+ },
+ {
+ field: 'Target.process.thread.Ext.start_address_details',
+ type: 'nested' as const,
+ entries: [
+ {
+ field: 'allocation_type',
+ operator: 'included' as const,
+ type: 'match' as const,
+ value: Target?.process?.thread?.Ext?.start_address_details?.allocation_type ?? '',
+ },
+ {
+ field: 'allocation_size',
+ operator: 'included' as const,
+ type: 'match' as const,
+ value: String(Target?.process?.thread?.Ext?.start_address_details?.allocation_size) ?? '',
+ },
+ {
+ field: 'region_size',
+ operator: 'included' as const,
+ type: 'match' as const,
+ value: String(Target?.process?.thread?.Ext?.start_address_details?.region_size) ?? '',
+ },
+ {
+ field: 'region_protection',
+ operator: 'included' as const,
+ type: 'match' as const,
+ value:
+ String(Target?.process?.thread?.Ext?.start_address_details?.region_protection) ?? '',
+ },
+ {
+ field: 'memory_pe.imphash',
+ operator: 'included' as const,
+ type: 'match' as const,
+ value:
+ String(Target?.process?.thread?.Ext?.start_address_details?.memory_pe?.imphash) ?? '',
+ },
+ ],
+ },
+ ]);
+
return {
...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }),
- entries: addIdToEntries([
- {
- field: 'Memory_protection.feature',
- operator: 'included',
- type: 'match',
- value: alertEcsData.Memory_protection?.feature ?? '',
- },
- {
- field: 'Memory_protection.self_injection',
- operator: 'included',
- type: 'match',
- value: String(alertEcsData.Memory_protection?.self_injection) ?? '',
- },
- {
- field: 'process.executable.caseless',
- operator: 'included',
- type: 'match',
- value: process?.executable ?? '',
- },
- {
- field: 'process.name.caseless',
- operator: 'included',
- type: 'match',
- value: process?.name ?? '',
- },
- {
- field: 'process.Ext.token.integrity_level_name',
- operator: 'included',
- type: 'match',
- value: process?.Ext?.token?.integrity_level_name ?? '',
- },
- {
- field: 'Target.process.thread.Ext.start_address_details',
- type: 'nested',
- entries: [
- {
- field: 'allocation_type',
- operator: 'included',
- type: 'match',
- value: Target?.process?.thread?.Ext?.start_address_details?.allocation_type ?? '',
- },
- {
- field: 'allocation_size',
- operator: 'included',
- type: 'match',
- value:
- String(Target?.process?.thread?.Ext?.start_address_details?.allocation_size) ?? '',
- },
- {
- field: 'region_size',
- operator: 'included',
- type: 'match',
- value: String(Target?.process?.thread?.Ext?.start_address_details?.region_size) ?? '',
- },
- {
- field: 'region_protection',
- operator: 'included',
- type: 'match',
- value:
- String(Target?.process?.thread?.Ext?.start_address_details?.region_protection) ?? '',
- },
- {
- field: 'memory_pe.imphash',
- operator: 'included',
- type: 'match',
- value:
- String(Target?.process?.thread?.Ext?.start_address_details?.memory_pe?.imphash) ?? '',
- },
- ],
- },
- ]),
+ entries: addIdToEntries(entries),
};
};
+
/**
* Determines whether or not any entries within the given exceptionItems contain values not in the specified ECS mapping
*/
diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json
index 07511506402a6..6c33c5f8e3ba6 100644
--- a/x-pack/plugins/translations/translations/ja-JP.json
+++ b/x-pack/plugins/translations/translations/ja-JP.json
@@ -1715,7 +1715,6 @@
"discover.howToChangeTheTimeTooltip": "時刻を変更するには、グローバル時刻フィルターを使用します。",
"discover.howToSeeOtherMatchingDocumentsDescription": "これらは検索条件に一致した初めの {sampleSize} 件のドキュメントです。他の結果を表示するには検索条件を絞ってください。",
"discover.howToSeeOtherMatchingDocumentsDescriptionGrid": "これらは検索条件に一致した初めの {sampleSize} 件のドキュメントです。他の結果を表示するには検索条件を絞ってください。",
- "discover.inspectorRequestDataTitle": "データ",
"discover.inspectorRequestDescriptionDocument": "このリクエストはElasticsearchにクエリをかけ、検索データを取得します。",
"discover.json.codeEditorAriaLabel": "Elasticsearch ドキュメントの JSON ビューのみを読み込む",
"discover.json.copyToClipboardLabel": "クリップボードにコピー",
@@ -13314,12 +13313,10 @@
"xpack.lens.breadcrumbsByValue": "ビジュアライゼーションを編集",
"xpack.lens.breadcrumbsCreate": "作成",
"xpack.lens.breadcrumbsTitle": "Visualizeライブラリ",
- "xpack.lens.chartSwitch.dataLossDescription": "このグラフタイプを選択すると、現在適用されている構成選択の一部が失われます。",
"xpack.lens.chartSwitch.dataLossLabel": "警告",
"xpack.lens.chartSwitch.experimentalLabel": "実験的",
"xpack.lens.chartSwitch.noResults": "{term}の結果が見つかりませんでした。",
"xpack.lens.chartTitle.unsaved": "保存されていないビジュアライゼーション",
- "xpack.lens.configPanel.chartType": "チャートタイプ",
"xpack.lens.configPanel.color.tooltip.auto": "カスタム色を指定しない場合、Lensは自動的に色を選択します。",
"xpack.lens.configPanel.color.tooltip.custom": "[自動]モードに戻すには、カスタム色をオフにしてください。",
"xpack.lens.configPanel.color.tooltip.disabled": "レイヤーに「内訳条件」が含まれている場合は、個別の系列をカスタム色にできません。",
@@ -13543,7 +13540,6 @@
"xpack.lens.indexPattern.cardinality": "ユニークカウント",
"xpack.lens.indexPattern.cardinality.signature": "フィールド:文字列",
"xpack.lens.indexPattern.cardinalityOf": "{name} のユニークカウント",
- "xpack.lens.indexPattern.changeIndexPatternTitle": "インデックスパターンを変更",
"xpack.lens.indexPattern.chooseField": "フィールドを選択",
"xpack.lens.indexPattern.chooseFieldLabel": "この関数を使用するには、フィールドを選択してください。",
"xpack.lens.indexPattern.chooseSubFunction": "サブ関数を選択",
@@ -13777,7 +13773,6 @@
"xpack.lens.indexPatterns.actionsPopoverLabel": "インデックスパターン設定",
"xpack.lens.indexPatterns.addFieldButton": "フィールドをインデックスパターンに追加",
"xpack.lens.indexPatterns.clearFiltersLabel": "名前とタイプフィルターを消去",
- "xpack.lens.indexPatterns.fieldFiltersLabel": "フィールドフィルター",
"xpack.lens.indexPatterns.filterByNameLabel": "検索フィールド名",
"xpack.lens.indexPatterns.manageFieldButton": "インデックスパターンを管理",
"xpack.lens.indexPatterns.noAvailableDataLabel": "データを含むフィールドはありません。",
@@ -13807,7 +13802,6 @@
"xpack.lens.pie.groupLabel": "比率",
"xpack.lens.pie.groupsizeLabel": "サイズ単位",
"xpack.lens.pie.pielabel": "円",
- "xpack.lens.pie.pieWithNegativeWarningLabel": "{chartType}グラフは負の値では表示できません。別のグラフタイプを試してください。",
"xpack.lens.pie.sliceGroupLabel": "スライス",
"xpack.lens.pie.suggestionLabel": "{chartName}",
"xpack.lens.pie.treemapGroupLabel": "グループ分けの条件",
@@ -13905,7 +13899,6 @@
"xpack.lens.visualizeGeoFieldMessage": "Lensは{fieldType}フィールドを可視化できません",
"xpack.lens.xyChart.addLayer": "レイヤーを追加",
"xpack.lens.xyChart.addLayerButton": "レイヤーを追加",
- "xpack.lens.xyChart.addLayerTooltip": "複数のレイヤーを使用すると、グラフタイプを組み合わせたり、別のインデックスパターンを可視化したりすることができます。",
"xpack.lens.xyChart.axisExtent.custom": "カスタム",
"xpack.lens.xyChart.axisExtent.dataBounds": "データ境界",
"xpack.lens.xyChart.axisExtent.disabledDataBoundsMessage": "折れ線グラフのみをデータ境界に合わせることができます",
diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json
index 4a35d27fbd8b0..af269112574f9 100644
--- a/x-pack/plugins/translations/translations/zh-CN.json
+++ b/x-pack/plugins/translations/translations/zh-CN.json
@@ -1721,11 +1721,9 @@
"discover.helpMenu.appName": "Discover",
"discover.hideChart": "隐藏图表",
"discover.histogramOfFoundDocumentsAriaLabel": "已找到文档的直方图",
- "discover.hitsPluralTitle": "{hits, plural, other {命中}}",
"discover.howToChangeTheTimeTooltip": "要更改时间,请使用全局时间筛选。",
"discover.howToSeeOtherMatchingDocumentsDescription": "下面是与您的搜索匹配的前 {sampleSize} 个文档,请优化您的搜索以查看其他文档。",
"discover.howToSeeOtherMatchingDocumentsDescriptionGrid": "下面是与您的搜索匹配的前 {sampleSize} 个文档,请优化您的搜索以查看其他文档。",
- "discover.inspectorRequestDataTitle": "数据",
"discover.inspectorRequestDescriptionDocument": "此请求将查询 Elasticsearch 以获取搜索的数据。",
"discover.json.codeEditorAriaLabel": "Elasticsearch 文档的只读 JSON 视图",
"discover.json.copyToClipboardLabel": "复制到剪贴板",
@@ -13661,13 +13659,11 @@
"xpack.lens.breadcrumbsByValue": "编辑可视化",
"xpack.lens.breadcrumbsCreate": "创建",
"xpack.lens.breadcrumbsTitle": "Visualize 库",
- "xpack.lens.chartSwitch.dataLossDescription": "选择此图表类型将使当前应用的配置选择部分丢失。",
"xpack.lens.chartSwitch.dataLossLabel": "警告",
"xpack.lens.chartSwitch.experimentalLabel": "实验性",
"xpack.lens.chartSwitch.noResults": "找不到 {term} 的结果。",
"xpack.lens.chartTitle.unsaved": "未保存的可视化",
"xpack.lens.chartWarnings.number": "{warningsCount} 个{warningsCount, plural, other {警告}}",
- "xpack.lens.configPanel.chartType": "图表类型",
"xpack.lens.configPanel.color.tooltip.auto": "Lens 自动为您选取颜色,除非您指定定制颜色。",
"xpack.lens.configPanel.color.tooltip.custom": "清除定制颜色以返回到“自动”模式。",
"xpack.lens.configPanel.color.tooltip.disabled": "当图层包括“细分依据”,各个系列无法定制颜色。",
@@ -13898,7 +13894,6 @@
"xpack.lens.indexPattern.cardinality": "唯一计数",
"xpack.lens.indexPattern.cardinality.signature": "field: string",
"xpack.lens.indexPattern.cardinalityOf": "{name} 的唯一计数",
- "xpack.lens.indexPattern.changeIndexPatternTitle": "更改索引模式",
"xpack.lens.indexPattern.chooseField": "选择字段",
"xpack.lens.indexPattern.chooseFieldLabel": "要使用此函数,请选择字段。",
"xpack.lens.indexPattern.chooseSubFunction": "选择子函数",
@@ -14135,7 +14130,6 @@
"xpack.lens.indexPatterns.actionsPopoverLabel": "索引模式设置",
"xpack.lens.indexPatterns.addFieldButton": "将字段添加到索引模式",
"xpack.lens.indexPatterns.clearFiltersLabel": "清除名称和类型筛选",
- "xpack.lens.indexPatterns.fieldFiltersLabel": "字段筛选",
"xpack.lens.indexPatterns.fieldSearchLiveRegion": "{availableFields} 个可用{availableFields, plural, other {字段}}。{emptyFields} 个空{emptyFields, plural, other {字段}}。{metaFields} 个元{metaFields, plural,other {字段}}。",
"xpack.lens.indexPatterns.filterByNameLabel": "搜索字段名称",
"xpack.lens.indexPatterns.manageFieldButton": "管理索引模式字段",
@@ -14166,7 +14160,6 @@
"xpack.lens.pie.groupLabel": "比例",
"xpack.lens.pie.groupsizeLabel": "大小调整依据",
"xpack.lens.pie.pielabel": "饼图",
- "xpack.lens.pie.pieWithNegativeWarningLabel": "{chartType} 图表无法使用负值进行呈现。请尝试不同的图表类型。",
"xpack.lens.pie.sliceGroupLabel": "切片依据",
"xpack.lens.pie.suggestionLabel": "为 {chartName}",
"xpack.lens.pie.treemapGroupLabel": "分组依据",
@@ -14264,7 +14257,6 @@
"xpack.lens.visualizeGeoFieldMessage": "Lens 无法可视化 {fieldType} 字段",
"xpack.lens.xyChart.addLayer": "添加图层",
"xpack.lens.xyChart.addLayerButton": "添加图层",
- "xpack.lens.xyChart.addLayerTooltip": "使用多个图层以组合图表类型或可视化不同的索引模式。",
"xpack.lens.xyChart.axisExtent.custom": "定制",
"xpack.lens.xyChart.axisExtent.dataBounds": "数据边界",
"xpack.lens.xyChart.axisExtent.disabledDataBoundsMessage": "仅折线图可适应数据边界",
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx
index 762526dfd7fa7..f990e12ed76e5 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx
+++ b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx
@@ -153,7 +153,7 @@ const TlsError = ({ docLinks, className }: PromptErrorProps) => (
}
diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.test.ts b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.test.ts
index de92cfeb29e08..84f405d6ee494 100644
--- a/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.test.ts
+++ b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.test.ts
@@ -80,22 +80,6 @@ describe('synthetics runtime types', () => {
maxSteps: 1,
ref: {
screenshotRef: refResult,
- blocks: [
- {
- id: 'hash1',
- synthetics: {
- blob: 'image data',
- blob_mime: 'image/jpeg',
- },
- },
- {
- id: 'hash2',
- synthetics: {
- blob: 'image data',
- blob_mime: 'image/jpeg',
- },
- },
- ],
},
};
});
diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts
index cd6be645c7a62..e7948f4ad532c 100644
--- a/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts
+++ b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts
@@ -82,10 +82,9 @@ export const FullScreenshotType = t.type({
synthetics: t.intersection([
t.partial({
blob: t.string,
+ blob_mime: t.string,
}),
t.type({
- blob: t.string,
- blob_mime: t.string,
step: t.type({
name: t.string,
}),
@@ -158,6 +157,10 @@ export const ScreenshotBlockDocType = t.type({
export type ScreenshotBlockDoc = t.TypeOf;
+export function isScreenshotBlockDoc(data: unknown): data is ScreenshotBlockDoc {
+ return isRight(ScreenshotBlockDocType.decode(data));
+}
+
/**
* Contains the fields requried by the Synthetics UI when utilizing screenshot refs.
*/
@@ -166,7 +169,6 @@ export const ScreenshotRefImageDataType = t.type({
maxSteps: t.number,
ref: t.type({
screenshotRef: RefResultType,
- blocks: t.array(ScreenshotBlockDocType),
}),
});
diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx
index 8e2dc1b4c24e0..df4c73908b627 100644
--- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx
+++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/ping_timestamp.tsx
@@ -57,7 +57,7 @@ export const PingTimestamp = ({ label, checkGroup, initialStepNo = 1 }: Props) =
const { data, status } = useFetcher(() => {
if (intersection && intersection.intersectionRatio === 1 && !stepImages[stepNumber - 1])
return getJourneyScreenshot(imgPath);
- }, [intersection?.intersectionRatio, stepNumber]);
+ }, [intersection?.intersectionRatio, stepNumber, imgPath]);
const [screenshotRef, setScreenshotRef] = useState(undefined);
useEffect(() => {
diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_popover.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_popover.tsx
index 6b78c4046da95..73c43da98bfc4 100644
--- a/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_popover.tsx
+++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/columns/ping_timestamp/step_image_popover.tsx
@@ -64,7 +64,7 @@ const RecomposedScreenshotImage: React.FC<
}
> = ({ captionContent, imageCaption, imageData, imgRef, setImageData }) => {
// initially an undefined URL value is passed to the image display, and a loading spinner is rendered.
- // `useCompositeImage` will call `setUrl` when the image is composited, and the updated `url` will display.
+ // `useCompositeImage` will call `setImageData` when the image is composited, and the updated `imageData` will display.
useCompositeImage(imgRef, setImageData, imageData);
return (
diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx
index c24ecd9183865..9d0555d97cbd4 100644
--- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx
+++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx
@@ -109,7 +109,7 @@ export const StepDetailContainer: React.FC = ({ checkGroup, stepIndex })
{(!journey || journey.loading) && (
-
+
)}
diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/step_screenshots.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/step_screenshots.tsx
index 316154929320d..54f73fb39a52a 100644
--- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/step_screenshots.tsx
+++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_expanded_row/step_screenshots.tsx
@@ -60,8 +60,8 @@ export const StepScreenshots = ({ step }: Props) => {
{
{
// expect only one accordion to be expanded
expect(Object.keys(result.current.expandedRows)).toEqual(['0']);
});
+
+ describe('getExpandedStepCallback', () => {
+ it('matches step index to key', () => {
+ const callback = getExpandedStepCallback(2);
+ expect(callback(defaultSteps[0])).toBe(false);
+ expect(callback(defaultSteps[1])).toBe(true);
+ });
+ });
});
diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.tsx
index 4b50a94f602b7..e58e1cca8660b 100644
--- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.tsx
+++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_expanded_row.tsx
@@ -18,6 +18,10 @@ interface HookProps {
type ExpandRowType = Record;
+export function getExpandedStepCallback(key: number) {
+ return (step: JourneyStep) => step.synthetics?.step?.index === key;
+}
+
export const useExpandedRow = ({ loading, steps, allSteps }: HookProps) => {
const [expandedRows, setExpandedRows] = useState({});
// eui table uses index from 0, synthetics uses 1
@@ -37,21 +41,18 @@ export const useExpandedRow = ({ loading, steps, allSteps }: HookProps) => {
useEffect(() => {
const expandedRowsN: ExpandRowType = {};
- for (const expandedRowKeyStr in expandedRows) {
- if (expandedRows.hasOwnProperty(expandedRowKeyStr)) {
- const expandedRowKey = Number(expandedRowKeyStr);
- const step = steps.find((stepF) => stepF.synthetics?.step?.index !== expandedRowKey);
+ for (const expandedRowKey of Object.keys(expandedRows).map((key) => Number(key))) {
+ const step = steps.find(getExpandedStepCallback(expandedRowKey + 1));
- if (step) {
- expandedRowsN[expandedRowKey] = (
-
- );
- }
+ if (step) {
+ expandedRowsN[expandedRowKey] = (
+
+ );
}
}
diff --git a/x-pack/plugins/uptime/public/components/synthetics/executed_step.tsx b/x-pack/plugins/uptime/public/components/synthetics/executed_step.tsx
index c3016864c72a7..add34c3f71f0d 100644
--- a/x-pack/plugins/uptime/public/components/synthetics/executed_step.tsx
+++ b/x-pack/plugins/uptime/public/components/synthetics/executed_step.tsx
@@ -51,7 +51,7 @@ export const ExecutedStep: FC = ({
return (
{loading ? (
-
+
) : (
<>
diff --git a/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx b/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx
index 8d35df51c2421..5b86ed525bc31 100644
--- a/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx
+++ b/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.test.tsx
@@ -30,7 +30,7 @@ describe('StepScreenshotDisplayProps', () => {
const { getByAltText } = render(
{
const { getByAltText } = render(
@@ -57,7 +57,7 @@ describe('StepScreenshotDisplayProps', () => {
const { getByTestId } = render(
{
const { getByAltText } = render(
= ({
checkGroup,
- isScreenshotBlob: isScreenshotBlob,
+ isFullScreenshot: isScreenshotBlob,
isScreenshotRef,
stepIndex,
stepName,
@@ -134,7 +134,7 @@ export const StepScreenshotDisplay: FC = ({
if (isScreenshotRef) {
return getJourneyScreenshot(imgSrc);
}
- }, [basePath, checkGroup, stepIndex, isScreenshotRef]);
+ }, [basePath, checkGroup, imgSrc, stepIndex, isScreenshotRef]);
const refDimensions = useMemo(() => {
if (isAScreenshotRef(screenshotRef)) {
diff --git a/x-pack/plugins/uptime/public/hooks/use_composite_image.test.tsx b/x-pack/plugins/uptime/public/hooks/use_composite_image.test.tsx
new file mode 100644
index 0000000000000..79e0cde1eaab8
--- /dev/null
+++ b/x-pack/plugins/uptime/public/hooks/use_composite_image.test.tsx
@@ -0,0 +1,200 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import * as redux from 'react-redux';
+import { renderHook } from '@testing-library/react-hooks';
+import { ScreenshotRefImageData } from '../../common/runtime_types';
+import { ScreenshotBlockCache } from '../state/reducers/synthetics';
+import { shouldCompose, useCompositeImage } from './use_composite_image';
+import * as compose from '../lib/helper/compose_screenshot_images';
+
+const MIME = 'image/jpeg';
+
+describe('use composite image', () => {
+ let imageData: string | undefined;
+ let imgRef: ScreenshotRefImageData;
+ let curRef: ScreenshotRefImageData;
+ let blocks: ScreenshotBlockCache;
+
+ beforeEach(() => {
+ imgRef = {
+ stepName: 'step-1',
+ maxSteps: 3,
+ ref: {
+ screenshotRef: {
+ '@timestamp': '123',
+ monitor: {
+ check_group: 'check-group',
+ },
+ screenshot_ref: {
+ width: 100,
+ height: 200,
+ blocks: [
+ {
+ hash: 'hash1',
+ top: 0,
+ left: 0,
+ width: 10,
+ height: 10,
+ },
+ {
+ hash: 'hash2',
+ top: 0,
+ left: 10,
+ width: 10,
+ height: 10,
+ },
+ ],
+ },
+ synthetics: {
+ package_version: 'v1',
+ step: { index: 0, name: 'first' },
+ type: 'step/screenshot_ref',
+ },
+ },
+ },
+ };
+ curRef = {
+ stepName: 'step-1',
+ maxSteps: 3,
+ ref: {
+ screenshotRef: {
+ '@timestamp': '234',
+ monitor: {
+ check_group: 'check-group-2',
+ },
+ screenshot_ref: {
+ width: 100,
+ height: 200,
+ blocks: [
+ {
+ hash: 'hash1',
+ top: 0,
+ left: 0,
+ width: 10,
+ height: 10,
+ },
+ {
+ hash: 'hash2',
+ top: 0,
+ left: 10,
+ width: 10,
+ height: 10,
+ },
+ ],
+ },
+ synthetics: {
+ package_version: 'v1',
+ step: { index: 1, name: 'second' },
+ type: 'step/screenshot_ref',
+ },
+ },
+ },
+ };
+ blocks = {
+ hash1: {
+ id: 'id1',
+ synthetics: {
+ blob: 'blob',
+ blob_mime: MIME,
+ },
+ },
+ hash2: {
+ id: 'id2',
+ synthetics: {
+ blob: 'blob',
+ blob_mime: MIME,
+ },
+ },
+ };
+ });
+
+ describe('shouldCompose', () => {
+ it('returns true if all blocks are loaded and ref is new', () => {
+ expect(shouldCompose(imageData, imgRef, curRef, blocks)).toBe(true);
+ });
+
+ it('returns false if a required block is pending', () => {
+ blocks.hash2 = { status: 'pending' };
+ expect(shouldCompose(imageData, imgRef, curRef, blocks)).toBe(false);
+ });
+
+ it('returns false if a required block is missing', () => {
+ delete blocks.hash2;
+ expect(shouldCompose(imageData, imgRef, curRef, blocks)).toBe(false);
+ });
+
+ it('returns false if imageData is defined and the refs have matching step index/check_group', () => {
+ imageData = 'blob';
+ curRef.ref.screenshotRef.synthetics.step.index = 0;
+ curRef.ref.screenshotRef.monitor.check_group = 'check-group';
+ expect(shouldCompose(imageData, imgRef, curRef, blocks)).toBe(false);
+ });
+
+ it('returns true if imageData is defined and the refs have different step names', () => {
+ imageData = 'blob';
+ curRef.ref.screenshotRef.synthetics.step.index = 0;
+ expect(shouldCompose(imageData, imgRef, curRef, blocks)).toBe(true);
+ });
+ });
+
+ describe('useCompositeImage', () => {
+ let useDispatchMock: jest.Mock;
+ let canvasMock: unknown;
+ let removeChildSpy: jest.Mock;
+ let selectorSpy: jest.SpyInstance;
+ let composeSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ useDispatchMock = jest.fn();
+ removeChildSpy = jest.fn();
+ canvasMock = {
+ parentElement: {
+ removeChild: removeChildSpy,
+ },
+ toDataURL: jest.fn().mockReturnValue('compose success'),
+ };
+ // @ts-expect-error mocking canvas element for testing
+ jest.spyOn(document, 'createElement').mockReturnValue(canvasMock);
+ jest.spyOn(redux, 'useDispatch').mockReturnValue(useDispatchMock);
+ selectorSpy = jest.spyOn(redux, 'useSelector').mockReturnValue({ blocks });
+ composeSpy = jest
+ .spyOn(compose, 'composeScreenshotRef')
+ .mockReturnValue(new Promise((r) => r([])));
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('does not compose if all blocks are not loaded', () => {
+ blocks = {};
+ renderHook(() => useCompositeImage(imgRef, jest.fn(), imageData));
+
+ expect(useDispatchMock).toHaveBeenCalledWith({
+ payload: ['hash1', 'hash2'],
+ type: 'FETCH_BLOCKS',
+ });
+ });
+
+ it('composes when all required blocks are loaded', async () => {
+ const onComposeImageSuccess = jest.fn();
+ const { waitFor } = renderHook(() => useCompositeImage(imgRef, onComposeImageSuccess));
+
+ expect(selectorSpy).toHaveBeenCalled();
+ expect(composeSpy).toHaveBeenCalledTimes(1);
+ expect(composeSpy.mock.calls[0][0]).toEqual(imgRef);
+ expect(composeSpy.mock.calls[0][1]).toBe(canvasMock);
+ expect(composeSpy.mock.calls[0][2]).toBe(blocks);
+
+ await waitFor(() => {
+ expect(onComposeImageSuccess).toHaveBeenCalledTimes(1);
+ expect(onComposeImageSuccess).toHaveBeenCalledWith('compose success');
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/uptime/public/hooks/use_composite_image.ts b/x-pack/plugins/uptime/public/hooks/use_composite_image.ts
index 6db3d05b8c968..3af1e798d43e1 100644
--- a/x-pack/plugins/uptime/public/hooks/use_composite_image.ts
+++ b/x-pack/plugins/uptime/public/hooks/use_composite_image.ts
@@ -5,19 +5,70 @@
* 2.0.
*/
+import { useDispatch, useSelector } from 'react-redux';
import React from 'react';
import { composeScreenshotRef } from '../lib/helper/compose_screenshot_images';
import { ScreenshotRefImageData } from '../../common/runtime_types/ping/synthetics';
+import {
+ fetchBlocksAction,
+ isPendingBlock,
+ ScreenshotBlockCache,
+ StoreScreenshotBlock,
+} from '../state/reducers/synthetics';
+import { syntheticsSelector } from '../state/selectors';
+
+function allBlocksLoaded(blocks: { [key: string]: StoreScreenshotBlock }, hashes: string[]) {
+ for (const hash of hashes) {
+ if (!blocks[hash] || isPendingBlock(blocks[hash])) {
+ return false;
+ }
+ }
+ return true;
+}
/**
* Checks if two refs are the same. If the ref is unchanged, there's no need
* to run the expensive draw procedure.
+ *
+ * The key fields here are `step.index` and `check_group`, as there's a 1:1 between
+ * journey and check group, and each step has a unique index within a journey.
*/
-function isNewRef(a: ScreenshotRefImageData, b: ScreenshotRefImageData): boolean {
- if (typeof a === 'undefined' || typeof b === 'undefined') return false;
- const stepA = a.ref.screenshotRef.synthetics.step;
- const stepB = b.ref.screenshotRef.synthetics.step;
- return stepA.index !== stepB.index || stepA.name !== stepB.name;
+const isNewRef = (
+ {
+ ref: {
+ screenshotRef: {
+ synthetics: {
+ step: { index: indexA },
+ },
+ monitor: { check_group: checkGroupA },
+ },
+ },
+ }: ScreenshotRefImageData,
+ {
+ ref: {
+ screenshotRef: {
+ synthetics: {
+ step: { index: indexB },
+ },
+ monitor: { check_group: checkGroupB },
+ },
+ },
+ }: ScreenshotRefImageData
+): boolean => indexA !== indexB || checkGroupA !== checkGroupB;
+
+export function shouldCompose(
+ imageData: string | undefined,
+ imgRef: ScreenshotRefImageData,
+ curRef: ScreenshotRefImageData,
+ blocks: ScreenshotBlockCache
+): boolean {
+ return (
+ allBlocksLoaded(
+ blocks,
+ imgRef.ref.screenshotRef.screenshot_ref.blocks.map(({ hash }) => hash)
+ ) &&
+ (typeof imageData === 'undefined' || isNewRef(imgRef, curRef))
+ );
}
/**
@@ -31,25 +82,34 @@ export const useCompositeImage = (
onComposeImageSuccess: React.Dispatch,
imageData?: string
): void => {
+ const dispatch = useDispatch();
+ const { blocks }: { blocks: ScreenshotBlockCache } = useSelector(syntheticsSelector);
+
+ React.useEffect(() => {
+ dispatch(
+ fetchBlocksAction(imgRef.ref.screenshotRef.screenshot_ref.blocks.map(({ hash }) => hash))
+ );
+ }, [dispatch, imgRef.ref.screenshotRef.screenshot_ref.blocks]);
+
const [curRef, setCurRef] = React.useState(imgRef);
React.useEffect(() => {
const canvas = document.createElement('canvas');
async function compose() {
- await composeScreenshotRef(imgRef, canvas);
- const imgData = canvas.toDataURL('image/png', 1.0);
+ await composeScreenshotRef(imgRef, canvas, blocks);
+ const imgData = canvas.toDataURL('image/jpg', 1.0);
onComposeImageSuccess(imgData);
}
// if the URL is truthy it means it's already been composed, so there
// is no need to call the function
- if (typeof imageData === 'undefined' || isNewRef(imgRef, curRef)) {
+ if (shouldCompose(imageData, imgRef, curRef, blocks)) {
compose();
setCurRef(imgRef);
}
return () => {
canvas.parentElement?.removeChild(canvas);
};
- }, [imgRef, onComposeImageSuccess, curRef, imageData]);
+ }, [blocks, curRef, imageData, imgRef, onComposeImageSuccess]);
};
diff --git a/x-pack/plugins/uptime/public/lib/__mocks__/screenshot_ref.mock.ts b/x-pack/plugins/uptime/public/lib/__mocks__/screenshot_ref.mock.ts
index d3a005d982168..a95aa77371b23 100644
--- a/x-pack/plugins/uptime/public/lib/__mocks__/screenshot_ref.mock.ts
+++ b/x-pack/plugins/uptime/public/lib/__mocks__/screenshot_ref.mock.ts
@@ -40,23 +40,5 @@ export const mockRef: ScreenshotRefImageData = {
},
monitor: { check_group: 'a567cc7a-c891-11eb-bdf9-3e22fb19bf97' },
},
- blocks: [
- {
- id: 'd518801fc523cf02727cd520f556c4113b3098c7',
- synthetics: {
- blob:
- '/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCABaAKADASIAAhEBAxEB/8QAHAABAAIDAQEBAAAAAAAAAAAAAAMFBAYHAggB/8QANRAAAQMDAwEHAwMCBwEAAAAAAQACAwQFEQYSITEHE0FRUpLRFBVhIjKBcaEIFiMkMzSxQv/EABkBAQADAQEAAAAAAAAAAAAAAAADBQYEAv/EACYRAQABBAEEAgEFAAAAAAAAAAABAgMEESEFEjFBBjJxE0JRYYH/2gAMAwEAAhEDEQA/APqlERAREQEREBERAREQEREBERAREQEREBERAREQeT4J5qn1XdDZbFU1rWB74wA1p6FxIAz+OVp+ldV3O6UdxfVd0BEWCNzG4ILieOvgAVFk3qcezVeq8Uxt12cK7dtzdp8ROnRTKxmA57QT+cL2CCucOc55LnuLnHkknJKvNNV7xUtpZHF0bwdmT+0jlZPA+WUZWRFmujtiqdRO98+t/lLe6fVbomqJ3ptqIi2KvEWHc7hR2uhlrbjVQUlJCN0k9RII2MGcZLjwOSsprg5oc0gtIyCPFB6REQERQPqIWVDIHSxtnkBc2MuAc4DqQOpwgnREQEREBERAREQcf7QdZXKK+z0FvlENPTkMcCxru8djnOQeOcYUuj7hLdLZWCop6SlxKwsmYxsDJpMEbT0Bdg54Vjr3SlD9ZLfqieSOmG01EDB+qQ8AbT4Z4z/JXP7ncZK90bdoipYRsgp2fsjb+PM+ZPJVhl2MXNwpxu37RqZ9wydzqGX03LquV1zMb3FO+Jj1uPUOiTQvik2yxuY/w3DCptS6vpNHPj3MFXdjhzaUOwImnxkPgSOjeviVV6Wv9dBWU9C+eV9LK7ug3hzoi7gOZnOCCc+S0+5dnepX3CUQNhuj3ykOlhqWOcXE9XBxDh+SVkek/D8TCyv1ci53RHNMTxz/AH/Ol3f+TXc3H1i0TFXifevw+hdI32n1LYqa50zSxsoLXMccljgcFufHnx8Rha7fdeVrNUVWn9KacqL/AHGgZHLXFtVHTRUweMsaXv6vI52gdPHri27ONOP0xpWnoKh7X1Jc6WYt6B7vAf0GB/C5ZZxNQ9rGvaCr1rPpupqaqCrgiLKYtqonRABzTOxxO3G0hp4wtBXFMVzFPh2WJrm3TNz7a5Zvadq+HVnYRrV30VTbrjQYpK6hqMF8EokYcZHDmkEEOHBC3683nUlufRw2LSv3mldTse6o+4xU+1/ILNrhk8AHP5/C5h2g2C22zsj7SbrR6kfqCquggNZUGSEhskbmNAxE0NacEZGPAK8u94rLn2jzabrtVT6atFHaqeriFM6KGWse4nc4SSNd+luACGrylbNYu0enqbRqWpv1tqrNXacy65Ub3tlcxvd941zHN4eHN6dP/CcO3641hcaamraLs5qzQVO18T5btTxy907kPdGTwcHO3OfBc/7N5NLSdoPaxZ6/UUN1tldTULTU11wZI6pibTvEx70EAhhftJH7cDyXrU9c7s40/DcND9okt5ZDLDDTWCsqIa36lrpGs7qJw/1G4BJGM4DcILnUOpNUUfb2Y7TpWruQjsUjI6YXKGFs0f1LP9wNzsDB/RtOHc+S3aW70b9caTprtZDBqGst087HmVrzRYEZli3Dh3JAyODtVDe7lR2j/ERbai51MNJT1WmpqeKWeRrGOkFSx5bkkc4HRZF8qIartu0LPTSxzQy2uveySNwc17T3RBBHBB80Eg7RrrdrtdabRmkKu90lsqH0c9a+thpY3Ts/cyPfkuxnrwP7Z6BbppqihppqymdSVEkTXyU7nNeYXEAlhc0kEg8ZBwccLk3YfqGzWSxXux3i40FuuluvNY2ogqZmxOIdKXNeA4jLSCMEccLrtNPFVU8U9NKyaGVofHJG4Oa9pGQQRwQR4oMhERAREQEREFTqa2/drJV0WQHSt/ST0DgcjP4yAuDXCiqbfVPp6yF8MzDgtcP7jzH5X0aenkoZqaGYDv4mSY8HNB/9U1q9NvhUdS6VTnTFcTqqOP8AHF9A2apuF7pqlsJ+lp373SPGG7hyAD4nOFu2ndL1lJeGVddIwd2S4BhyXk8fwOVu0bGsADWhoHAwMBe8LlyrVOTcprr/AG+EuB06jDt9m9zve36qe96bsV/MRvtmttyMWRGayljm2Z8twOFcIpFkpqXTVipbZNbKWy22C2zHMlJHSRtif0/cwDB6DqPBL1pmw30Q/fLJbLj3IIj+spI5u7B6hu4HH8K5RBUf5cseAPs1uwIjB/1Wf8Z6s6ftPl0WLbNGaXtVa2ttmnLLRVjc7Z6egijkGfJzWgrYUQVN70/Zr/FEy+Wm33KOIl0baymZMGE9SA4HBU1NabdS/Smlt9JCaSPuafu4Wt7iPgbGYH6W8DgccKwRBRXjSWnL1VCqvGn7RcKkANEtXRRyvAHhuc0lWtNBFS08UFNEyGGJoZHHG0NaxoGAABwAB4LIRAREQEREBERAREQEREBERAREQEREBERAREQEREBERBHul9DPcfhN0voZ7j8KREEe6X0M9x+E3S+hnuPwpEQR7pfQz3H4TdL6Ge4/CkRBHul9DPcfhN0voZ7j8KREEe6X0M9x+E3S+hnuPwpEQR7pfQz3H4TdL6Ge4/CkRBHul9DPcfhN0voZ7j8KREEe6X0M9x+E3S+hnuPwpEQR7pfQz3H4TdL6Ge4/CkRBHuk9DPcfhVV1vcdtexssL3ucM4jOcf1zhW/ktF1OT92k5/8AgL3RT3Ty4c/IqsW+6jy//9k=',
- blob_mime: 'image/jpeg',
- },
- },
- {
- id: 'fa90345d5d7b05b1601e9ee645e663bc358869e0',
- synthetics: {
- blob:
- '/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCABaAKADASIAAhEBAxEB/8QAHAABAAIDAQEBAAAAAAAAAAAAAAYIAwUHBAIB/8QANBAAAQQCAQICCAUDBQAAAAAAAQACAwQFEQYSIQcxExQWQVFSVKEiMmGS0RVTcQhDgZHh/8QAGQEBAAIDAAAAAAAAAAAAAAAAAAIEAwUG/8QAHxEBAAEEAwADAAAAAAAAAAAAAAEEExVRAgORBRIh/9oADAMBAAIRAxEAPwC1KIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIg+JHBkbnnyaCVyXj3jlhsqMRYs4PP43GZax6rUyViuz1Z8vUWhhe150dtI8vcfcCV1iyCa8oAJJYQAP8KvPg54WZPJ8D4qOY5TKwY/HWn3IuPzVG1xHK2aQtMjiOtwO+rR9zu3ZB3qDL46xYNeDIVJJ+p7TGyZpdtmuoaB3sbG/htZaORpZBshoW61psbul5gla/pPwOj2KrvjvD7J2uD+Kliph5q3KLuYvMozzxGKWWqXMcWxF2vwvBkGx2dvz7LyYvi+Vyd/MTcI4vk+K1TxWTG2GW63qvrVw76Q0bHU73el/nuHcLvPMPByvAYKCT1yfMOstimrSMfFEYI+t4eQ7YOj5AH9dLfR5fGyQVp48hUdDaf6OCQTtLZX7I6WHenHYI0Pgq18A47IefeGcuN4PmcEyhQt1crbs0XRMfY9Vc3rcfftx7Pdrq6gB+XQxcaxvI4cB4acXs8Sz0M/HuStluXDVJr9BmkeHscPNmn93a6Rrz7hBZ0ZKi6+aDbtY3gOo1xK30gHx6d70tJxbnGA5PPlYsPejlfjLD69jbgO7fN7e/dnf83kuI8B48/HZmjjuQcCy9/lsOdfcm5AA6GH0ZcSJvWR+duv9o9j/lfVHhLK2L8WMDZ4rlIn3LslmnNjKbWelqGRjmRQyHTTot2Yt+QIHdBYmhfqZGEy0LVezED0l8Ege3fw2CvWuJ/6fqeTo5TkLLGDFTGlkDYsg/EnFS2ntBBa6vst/DvXU0Dvvz327YgIiINXyPM0ePYS5lstO2ChUjMksh9w/wAe8k6AHvJChHG/FrGZfOY7GW8LnsN/VQX46xk6gjit6G9NcHHRI7gH3a+IC93jfxm5y/wxzeGxfe9Mxj4mF3SJHMka/oJ/Xp0N9t6Wm4/zPOcizGCx0PAsnj44AHZG3lq3oIqpa0dq52esk9hrXbX66DoozGMMDLAyNMwPk9C2T07el0nl0A711fp5rKclRF4UTcrC6R1CuZW+kI+PTvaq6cTySpxanxB3FM66zQ5ay9JdZVLqz4DKSHscO7vPZ0NADZI8ls8txzJY3xYns4XjN+/JazrbbxkcUHRsBO3Tw32OBYwe6N3l27HyQd04/wA1wfIM5l8RjLrZL2LeGWGbA3sebe/4gPIkeRW2gy+MsVp7MGRpy16+/TSMma5sevPqIOh5e9V6yvDMnFl/F3HYXj89bJZWBk2Mvw1AyGSH8JmhZMAA1z9kFuxsg78lro+NXLo5Ba4pw3L8fxrOITY+3WnpGF124QekMjHeRw+fWz/yNhZMZzFCOZ/9UoBkLGySu9YZpjXflc477A7GifNZ7eSo1Kgt27laCq7XTNLK1rDvy04nXdV1wHhnUfybhbbnFZPU5eJj+o+kqPDHXOkdptjXpQSezu4IHwGo9W4ryUcO8Np8zispNjqFe5Xs1H4g3pK0rpn+jc+q/RILOkA67AA+8ILcIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiCOe1Nb6eb7J7U1vp5vsoj5u7r9PkrFuHMZSo3HiW+1Nb6eb7J7U1vp5vsoiiW4MpUbjxLvamt9PN9k9qa30832URRLcGUqNx4l3tTW+nm+ye1Nb6eb7KIoluDKVG48S72prfTzfZPamt9PN9lEUS3BlKjceJd7U1vp5vsntTW/sTfZRFEtwZSo3HiW+1Nf+xN/0Fuq05sQtliawscNj8X/AIucKccYJOJi3+qhz4REfi98fW9nfz+vNteqX5GfuP8ACdUvyM/cf4WRFiblj6pfkZ+4/wAJ1S/Iz9x/hZEQY+qX5GfuP8J1S/Iz9x/hZEQf/9k=',
- blob_mime: 'image/jpeg',
- },
- },
- ],
},
};
diff --git a/x-pack/plugins/uptime/public/lib/__mocks__/uptime_store.mock.ts b/x-pack/plugins/uptime/public/lib/__mocks__/uptime_store.mock.ts
index 8ad3379615549..c0b4c893d93d8 100644
--- a/x-pack/plugins/uptime/public/lib/__mocks__/uptime_store.mock.ts
+++ b/x-pack/plugins/uptime/public/lib/__mocks__/uptime_store.mock.ts
@@ -111,4 +111,9 @@ export const mockState: AppState = {
},
journeys: {},
networkEvents: {},
+ synthetics: {
+ blocks: {},
+ cacheSize: 0,
+ hitCount: [],
+ },
};
diff --git a/x-pack/plugins/uptime/public/lib/helper/compose_screenshot_images.test.ts b/x-pack/plugins/uptime/public/lib/helper/compose_screenshot_images.test.ts
new file mode 100644
index 0000000000000..0bf809d4e7a40
--- /dev/null
+++ b/x-pack/plugins/uptime/public/lib/helper/compose_screenshot_images.test.ts
@@ -0,0 +1,87 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { ScreenshotRefImageData } from '../../../common/runtime_types/ping/synthetics';
+import { composeScreenshotRef } from './compose_screenshot_images';
+
+describe('composeScreenshotRef', () => {
+ let getContextMock: jest.Mock;
+ let drawImageMock: jest.Mock;
+ let ref: ScreenshotRefImageData;
+ let contextMock: unknown;
+
+ beforeEach(() => {
+ drawImageMock = jest.fn();
+ contextMock = {
+ drawImage: drawImageMock,
+ };
+ getContextMock = jest.fn().mockReturnValue(contextMock);
+ ref = {
+ stepName: 'step',
+ maxSteps: 3,
+ ref: {
+ screenshotRef: {
+ '@timestamp': '123',
+ monitor: { check_group: 'check-group' },
+ screenshot_ref: {
+ blocks: [
+ {
+ hash: '123',
+ top: 0,
+ left: 0,
+ width: 10,
+ height: 10,
+ },
+ ],
+ height: 100,
+ width: 100,
+ },
+ synthetics: {
+ package_version: 'v1',
+ step: {
+ name: 'step-name',
+ index: 0,
+ },
+ type: 'step/screenshot_ref',
+ },
+ },
+ },
+ };
+ });
+
+ it('throws error when blob does not exist', async () => {
+ try {
+ // @ts-expect-error incomplete invocation for test
+ await composeScreenshotRef(ref, { getContext: getContextMock }, {});
+ } catch (e: any) {
+ expect(e).toMatchInlineSnapshot(
+ `[Error: Error processing image. Expected image data with hash 123 is missing]`
+ );
+ expect(getContextMock).toHaveBeenCalled();
+ expect(getContextMock.mock.calls[0][0]).toBe('2d');
+ expect(getContextMock.mock.calls[0][1]).toEqual({ alpha: false });
+ }
+ });
+
+ it('throws error when block is pending', async () => {
+ try {
+ await composeScreenshotRef(
+ ref,
+ // @ts-expect-error incomplete invocation for test
+ { getContext: getContextMock },
+ { '123': { status: 'pending' } }
+ );
+ } catch (e: any) {
+ expect(e).toMatchInlineSnapshot(
+ `[Error: Error processing image. Expected image data with hash 123 is missing]`
+ );
+ expect(getContextMock).toHaveBeenCalled();
+ expect(getContextMock.mock.calls[0][0]).toBe('2d');
+ expect(getContextMock.mock.calls[0][1]).toEqual({ alpha: false });
+ }
+ });
+});
diff --git a/x-pack/plugins/uptime/public/lib/helper/compose_screenshot_images.ts b/x-pack/plugins/uptime/public/lib/helper/compose_screenshot_images.ts
index 7481a517d3c9e..60cd248c1487a 100644
--- a/x-pack/plugins/uptime/public/lib/helper/compose_screenshot_images.ts
+++ b/x-pack/plugins/uptime/public/lib/helper/compose_screenshot_images.ts
@@ -5,7 +5,11 @@
* 2.0.
*/
-import { ScreenshotRefImageData } from '../../../common/runtime_types';
+import {
+ isScreenshotBlockDoc,
+ ScreenshotRefImageData,
+} from '../../../common/runtime_types/ping/synthetics';
+import { ScreenshotBlockCache } from '../../state/reducers/synthetics';
/**
* Draws image fragments on a canvas.
@@ -15,30 +19,30 @@ import { ScreenshotRefImageData } from '../../../common/runtime_types';
*/
export async function composeScreenshotRef(
data: ScreenshotRefImageData,
- canvas: HTMLCanvasElement
+ canvas: HTMLCanvasElement,
+ blocks: ScreenshotBlockCache
) {
const {
- ref: { screenshotRef, blocks },
+ ref: { screenshotRef },
} = data;
+ const ctx = canvas.getContext('2d', { alpha: false });
+
canvas.width = screenshotRef.screenshot_ref.width;
canvas.height = screenshotRef.screenshot_ref.height;
- const ctx = canvas.getContext('2d', { alpha: false });
-
/**
* We need to treat each operation as an async task, otherwise we will race between drawing image
* chunks and extracting the final data URL from the canvas; without this, the image could be blank or incomplete.
*/
const drawOperations: Array> = [];
- for (const block of screenshotRef.screenshot_ref.blocks) {
+ for (const { hash, top, left, width, height } of screenshotRef.screenshot_ref.blocks) {
drawOperations.push(
new Promise((resolve, reject) => {
const img = new Image();
- const { top, left, width, height, hash } = block;
- const blob = blocks.find((b) => b.id === hash);
- if (!blob) {
+ const blob = blocks[hash];
+ if (!blob || !isScreenshotBlockDoc(blob)) {
reject(Error(`Error processing image. Expected image data with hash ${hash} is missing`));
} else {
img.onload = () => {
diff --git a/x-pack/plugins/uptime/public/state/api/journey.ts b/x-pack/plugins/uptime/public/state/api/journey.ts
index 4e71a07c70b68..8ed3fadf5c346 100644
--- a/x-pack/plugins/uptime/public/state/api/journey.ts
+++ b/x-pack/plugins/uptime/public/state/api/journey.ts
@@ -12,11 +12,16 @@ import {
FailedStepsApiResponseType,
JourneyStep,
JourneyStepType,
+ ScreenshotBlockDoc,
ScreenshotImageBlob,
ScreenshotRefImageData,
SyntheticsJourneyApiResponse,
SyntheticsJourneyApiResponseType,
-} from '../../../common/runtime_types';
+} from '../../../common/runtime_types/ping/synthetics';
+
+export async function fetchScreenshotBlockSet(params: string[]): Promise {
+ return apiService.post('/api/uptime/journey/screenshot/block', { hashes: params });
+}
export async function fetchJourneySteps(
params: FetchJourneyStepsParams
diff --git a/x-pack/plugins/uptime/public/state/effects/index.ts b/x-pack/plugins/uptime/public/state/effects/index.ts
index a5e9ffecadaf8..df02180b1c28d 100644
--- a/x-pack/plugins/uptime/public/state/effects/index.ts
+++ b/x-pack/plugins/uptime/public/state/effects/index.ts
@@ -20,6 +20,11 @@ import { fetchCertificatesEffect } from '../certificates/certificates';
import { fetchAlertsEffect } from '../alerts/alerts';
import { fetchJourneyStepsEffect } from './journey';
import { fetchNetworkEventsEffect } from './network_events';
+import {
+ fetchScreenshotBlocks,
+ generateBlockStatsOnPut,
+ pruneBlockCache,
+} from './synthetic_journey_blocks';
export function* rootEffect() {
yield fork(fetchMonitorDetailsEffect);
@@ -38,4 +43,7 @@ export function* rootEffect() {
yield fork(fetchAlertsEffect);
yield fork(fetchJourneyStepsEffect);
yield fork(fetchNetworkEventsEffect);
+ yield fork(fetchScreenshotBlocks);
+ yield fork(generateBlockStatsOnPut);
+ yield fork(pruneBlockCache);
}
diff --git a/x-pack/plugins/uptime/public/state/effects/synthetic_journey_blocks.ts b/x-pack/plugins/uptime/public/state/effects/synthetic_journey_blocks.ts
new file mode 100644
index 0000000000000..829048747ddf7
--- /dev/null
+++ b/x-pack/plugins/uptime/public/state/effects/synthetic_journey_blocks.ts
@@ -0,0 +1,81 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { Action } from 'redux-actions';
+import { call, fork, put, select, takeEvery, throttle } from 'redux-saga/effects';
+import { ScreenshotBlockDoc } from '../../../common/runtime_types/ping/synthetics';
+import { fetchScreenshotBlockSet } from '../api/journey';
+import {
+ fetchBlocksAction,
+ setBlockLoadingAction,
+ isPendingBlock,
+ pruneCacheAction,
+ putBlocksAction,
+ putCacheSize,
+ ScreenshotBlockCache,
+ updateHitCountsAction,
+} from '../reducers/synthetics';
+import { syntheticsSelector } from '../selectors';
+
+function* fetchBlocks(hashes: string[]) {
+ yield put(setBlockLoadingAction(hashes));
+ const blocks: ScreenshotBlockDoc[] = yield call(fetchScreenshotBlockSet, hashes);
+ yield put(putBlocksAction({ blocks }));
+}
+
+export function* fetchScreenshotBlocks() {
+ /**
+ * We maintain a list of each hash and how many times it is requested so we can avoid
+ * subsequent re-requests if the block is dropped due to cache pruning.
+ */
+ yield takeEvery(String(fetchBlocksAction), function* (action: Action) {
+ if (action.payload.length > 0) {
+ yield put(updateHitCountsAction(action.payload));
+ }
+ });
+
+ /**
+ * We do a short delay to allow multiple item renders to queue up before dispatching
+ * a fetch to the backend.
+ */
+ yield throttle(20, String(fetchBlocksAction), function* () {
+ const { blocks }: { blocks: ScreenshotBlockCache } = yield select(syntheticsSelector);
+ const toFetch = Object.keys(blocks).filter((hash) => {
+ const block = blocks[hash];
+ return isPendingBlock(block) && block.status !== 'loading';
+ });
+
+ if (toFetch.length > 0) {
+ yield fork(fetchBlocks, toFetch);
+ }
+ });
+}
+
+export function* generateBlockStatsOnPut() {
+ yield takeEvery(
+ String(putBlocksAction),
+ function* (action: Action<{ blocks: ScreenshotBlockDoc[] }>) {
+ const batchSize = action.payload.blocks.reduce((total, cur) => {
+ return cur.synthetics.blob.length + total;
+ }, 0);
+ yield put(putCacheSize(batchSize));
+ }
+ );
+}
+
+// 4 MB cap for cache size
+const MAX_CACHE_SIZE = 4000000;
+
+export function* pruneBlockCache() {
+ yield takeEvery(String(putCacheSize), function* (_action: Action) {
+ const { cacheSize }: { cacheSize: number } = yield select(syntheticsSelector);
+
+ if (cacheSize > MAX_CACHE_SIZE) {
+ yield put(pruneCacheAction(cacheSize - MAX_CACHE_SIZE));
+ }
+ });
+}
diff --git a/x-pack/plugins/uptime/public/state/reducers/index.ts b/x-pack/plugins/uptime/public/state/reducers/index.ts
index 05fb7c732466d..53cb6d6bffb0c 100644
--- a/x-pack/plugins/uptime/public/state/reducers/index.ts
+++ b/x-pack/plugins/uptime/public/state/reducers/index.ts
@@ -23,6 +23,7 @@ import { selectedFiltersReducer } from './selected_filters';
import { alertsReducer } from '../alerts/alerts';
import { journeyReducer } from './journey';
import { networkEventsReducer } from './network_events';
+import { syntheticsReducer } from './synthetics';
export const rootReducer = combineReducers({
monitor: monitorReducer,
@@ -42,4 +43,5 @@ export const rootReducer = combineReducers({
alerts: alertsReducer,
journeys: journeyReducer,
networkEvents: networkEventsReducer,
+ synthetics: syntheticsReducer,
});
diff --git a/x-pack/plugins/uptime/public/state/reducers/synthetics.test.ts b/x-pack/plugins/uptime/public/state/reducers/synthetics.test.ts
new file mode 100644
index 0000000000000..06d738d01b42f
--- /dev/null
+++ b/x-pack/plugins/uptime/public/state/reducers/synthetics.test.ts
@@ -0,0 +1,429 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import {
+ fetchBlocksAction,
+ isPendingBlock,
+ pruneCacheAction,
+ setBlockLoadingAction,
+ putBlocksAction,
+ putCacheSize,
+ syntheticsReducer,
+ SyntheticsReducerState,
+ updateHitCountsAction,
+} from './synthetics';
+
+const MIME = 'image/jpeg';
+
+describe('syntheticsReducer', () => {
+ jest.spyOn(Date, 'now').mockImplementation(() => 10);
+
+ describe('isPendingBlock', () => {
+ it('returns true for pending block', () => {
+ expect(isPendingBlock({ status: 'pending' })).toBe(true);
+ });
+
+ it('returns true for loading block', () => {
+ expect(isPendingBlock({ status: 'loading' })).toBe(true);
+ });
+
+ it('returns false for non-pending block', () => {
+ expect(isPendingBlock({ synthetics: { blob: 'blobdata', blob_mime: MIME } })).toBe(false);
+ expect(isPendingBlock({})).toBe(false);
+ });
+ });
+
+ describe('prune cache', () => {
+ let state: SyntheticsReducerState;
+
+ beforeEach(() => {
+ const blobs = ['large', 'large2', 'large3', 'large4'];
+ state = {
+ blocks: {
+ '123': {
+ synthetics: {
+ blob: blobs[0],
+ blob_mime: MIME,
+ },
+ id: '123',
+ },
+ '234': {
+ synthetics: {
+ blob: blobs[1],
+ blob_mime: MIME,
+ },
+ id: '234',
+ },
+ '345': {
+ synthetics: {
+ blob: blobs[2],
+ blob_mime: MIME,
+ },
+ id: '345',
+ },
+ '456': {
+ synthetics: {
+ blob: blobs[3],
+ blob_mime: MIME,
+ },
+ id: '456',
+ },
+ },
+ cacheSize: 23,
+ hitCount: [
+ { hash: '123', hitTime: 89 },
+ { hash: '234', hitTime: 23 },
+ { hash: '345', hitTime: 4 },
+ { hash: '456', hitTime: 1 },
+ ],
+ };
+ });
+
+ it('removes lowest common hits', () => {
+ // @ts-expect-error redux-actions doesn't handle types well
+ expect(syntheticsReducer(state, pruneCacheAction(10))).toMatchInlineSnapshot(`
+ Object {
+ "blocks": Object {
+ "123": Object {
+ "id": "123",
+ "synthetics": Object {
+ "blob": "large",
+ "blob_mime": "image/jpeg",
+ },
+ },
+ "234": Object {
+ "id": "234",
+ "synthetics": Object {
+ "blob": "large2",
+ "blob_mime": "image/jpeg",
+ },
+ },
+ },
+ "cacheSize": 11,
+ "hitCount": Array [
+ Object {
+ "hash": "123",
+ "hitTime": 89,
+ },
+ Object {
+ "hash": "234",
+ "hitTime": 23,
+ },
+ ],
+ }
+ `);
+ });
+
+ it('skips pending blocks', () => {
+ state.blocks = { ...state.blocks, '000': { status: 'pending' } };
+ state.hitCount.push({ hash: '000', hitTime: 1 });
+ // @ts-expect-error redux-actions doesn't handle types well
+ const newState = syntheticsReducer(state, pruneCacheAction(10));
+ expect(newState.blocks['000']).toEqual({ status: 'pending' });
+ });
+
+ it('ignores a hash from `hitCount` that does not exist', () => {
+ state.hitCount.push({ hash: 'not exist', hitTime: 1 });
+ // @ts-expect-error redux-actions doesn't handle types well
+ expect(syntheticsReducer(state, pruneCacheAction(2))).toMatchInlineSnapshot(`
+ Object {
+ "blocks": Object {
+ "123": Object {
+ "id": "123",
+ "synthetics": Object {
+ "blob": "large",
+ "blob_mime": "image/jpeg",
+ },
+ },
+ "234": Object {
+ "id": "234",
+ "synthetics": Object {
+ "blob": "large2",
+ "blob_mime": "image/jpeg",
+ },
+ },
+ "345": Object {
+ "id": "345",
+ "synthetics": Object {
+ "blob": "large3",
+ "blob_mime": "image/jpeg",
+ },
+ },
+ },
+ "cacheSize": 17,
+ "hitCount": Array [
+ Object {
+ "hash": "123",
+ "hitTime": 89,
+ },
+ Object {
+ "hash": "234",
+ "hitTime": 23,
+ },
+ Object {
+ "hash": "345",
+ "hitTime": 4,
+ },
+ ],
+ }
+ `);
+ });
+
+ it('will prune a block with an empty blob', () => {
+ state.blocks = {
+ ...state.blocks,
+ '000': { id: '000', synthetics: { blob: '', blob_mime: MIME } },
+ };
+ state.hitCount.push({ hash: '000', hitTime: 1 });
+ // @ts-expect-error redux-actions doesn't handle types well
+ const newState = syntheticsReducer(state, pruneCacheAction(10));
+ expect(Object.keys(newState.blocks)).not.toContain('000');
+ });
+ });
+
+ describe('fetch blocks', () => {
+ it('sets targeted blocks as pending', () => {
+ const state: SyntheticsReducerState = { blocks: {}, cacheSize: 0, hitCount: [] };
+ const action = fetchBlocksAction(['123', '234']);
+ // @ts-expect-error redux-actions doesn't handle types well
+ expect(syntheticsReducer(state, action)).toMatchInlineSnapshot(`
+ Object {
+ "blocks": Object {
+ "123": Object {
+ "status": "pending",
+ },
+ "234": Object {
+ "status": "pending",
+ },
+ },
+ "cacheSize": 0,
+ "hitCount": Array [],
+ }
+ `);
+ });
+
+ it('will not overwrite a cached block', () => {
+ const state: SyntheticsReducerState = {
+ blocks: { '123': { id: '123', synthetics: { blob: 'large', blob_mime: MIME } } },
+ cacheSize: 'large'.length,
+ hitCount: [{ hash: '123', hitTime: 1 }],
+ };
+ const action = fetchBlocksAction(['123']);
+ // @ts-expect-error redux-actions doesn't handle types well
+ expect(syntheticsReducer(state, action)).toMatchInlineSnapshot(`
+ Object {
+ "blocks": Object {
+ "123": Object {
+ "id": "123",
+ "synthetics": Object {
+ "blob": "large",
+ "blob_mime": "image/jpeg",
+ },
+ },
+ },
+ "cacheSize": 5,
+ "hitCount": Array [
+ Object {
+ "hash": "123",
+ "hitTime": 1,
+ },
+ ],
+ }
+ `);
+ });
+ });
+ describe('update hit counts', () => {
+ let state: SyntheticsReducerState;
+
+ beforeEach(() => {
+ const blobs = ['large', 'large2', 'large3'];
+ state = {
+ blocks: {
+ '123': {
+ synthetics: {
+ blob: blobs[0],
+ blob_mime: MIME,
+ },
+ id: '123',
+ },
+ '234': {
+ synthetics: {
+ blob: blobs[1],
+ blob_mime: MIME,
+ },
+ id: '234',
+ },
+ '345': {
+ synthetics: {
+ blob: blobs[2],
+ blob_mime: MIME,
+ },
+ id: '345',
+ },
+ },
+ cacheSize: 17,
+ hitCount: [
+ { hash: '123', hitTime: 1 },
+ { hash: '234', hitTime: 1 },
+ ],
+ };
+ });
+
+ it('increments hit count for selected hashes', () => {
+ // @ts-expect-error redux-actions doesn't handle types well
+ expect(syntheticsReducer(state, updateHitCountsAction(['123', '234'])).hitCount).toEqual([
+ {
+ hash: '123',
+ hitTime: 10,
+ },
+ { hash: '234', hitTime: 10 },
+ ]);
+ });
+
+ it('adds new hit count for missing item', () => {
+ // @ts-expect-error redux-actions doesn't handle types well
+ expect(syntheticsReducer(state, updateHitCountsAction(['345'])).hitCount).toEqual([
+ { hash: '345', hitTime: 10 },
+ { hash: '123', hitTime: 1 },
+ { hash: '234', hitTime: 1 },
+ ]);
+ });
+ });
+ describe('put cache size', () => {
+ let state: SyntheticsReducerState;
+
+ beforeEach(() => {
+ state = {
+ blocks: {},
+ cacheSize: 0,
+ hitCount: [],
+ };
+ });
+
+ it('updates the cache size', () => {
+ // @ts-expect-error redux-actions doesn't handle types well
+ expect(syntheticsReducer(state, putCacheSize(100))).toEqual({
+ blocks: {},
+ cacheSize: 100,
+ hitCount: [],
+ });
+ });
+ });
+
+ describe('in-flight blocks', () => {
+ let state: SyntheticsReducerState;
+
+ beforeEach(() => {
+ state = {
+ blocks: {
+ '123': { status: 'pending' },
+ },
+ cacheSize: 1,
+ hitCount: [{ hash: '123', hitTime: 1 }],
+ };
+ });
+
+ it('sets pending blocks to loading', () => {
+ // @ts-expect-error redux-actions doesn't handle types well
+ expect(syntheticsReducer(state, setBlockLoadingAction(['123']))).toEqual({
+ blocks: { '123': { status: 'loading' } },
+ cacheSize: 1,
+ hitCount: [{ hash: '123', hitTime: 1 }],
+ });
+ });
+ });
+
+ describe('put blocks', () => {
+ let state: SyntheticsReducerState;
+
+ beforeEach(() => {
+ state = {
+ blocks: {
+ '123': {
+ status: 'pending',
+ },
+ },
+ cacheSize: 0,
+ hitCount: [{ hash: '123', hitTime: 1 }],
+ };
+ });
+
+ it('resolves pending blocks', () => {
+ const action = putBlocksAction({
+ blocks: [
+ {
+ id: '123',
+ synthetics: {
+ blob: 'reallybig',
+ blob_mime: MIME,
+ },
+ },
+ ],
+ });
+ // @ts-expect-error redux-actions doesn't handle types well
+ const result = syntheticsReducer(state, action);
+ expect(result).toMatchInlineSnapshot(`
+ Object {
+ "blocks": Object {
+ "123": Object {
+ "id": "123",
+ "synthetics": Object {
+ "blob": "reallybig",
+ "blob_mime": "image/jpeg",
+ },
+ },
+ },
+ "cacheSize": 0,
+ "hitCount": Array [
+ Object {
+ "hash": "123",
+ "hitTime": 1,
+ },
+ ],
+ }
+ `);
+ });
+
+ it('keeps unresolved blocks', () => {
+ const action = putBlocksAction({
+ blocks: [
+ {
+ id: '234',
+ synthetics: {
+ blob: 'also big',
+ blob_mime: MIME,
+ },
+ },
+ ],
+ });
+ // @ts-expect-error redux-actions doesn't handle types well
+ expect(syntheticsReducer(state, action)).toMatchInlineSnapshot(`
+ Object {
+ "blocks": Object {
+ "123": Object {
+ "status": "pending",
+ },
+ "234": Object {
+ "id": "234",
+ "synthetics": Object {
+ "blob": "also big",
+ "blob_mime": "image/jpeg",
+ },
+ },
+ },
+ "cacheSize": 0,
+ "hitCount": Array [
+ Object {
+ "hash": "123",
+ "hitTime": 1,
+ },
+ ],
+ }
+ `);
+ });
+ });
+});
diff --git a/x-pack/plugins/uptime/public/state/reducers/synthetics.ts b/x-pack/plugins/uptime/public/state/reducers/synthetics.ts
new file mode 100644
index 0000000000000..1e97c3972444b
--- /dev/null
+++ b/x-pack/plugins/uptime/public/state/reducers/synthetics.ts
@@ -0,0 +1,180 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { createAction, handleActions, Action } from 'redux-actions';
+import {
+ isScreenshotBlockDoc,
+ ScreenshotBlockDoc,
+} from '../../../common/runtime_types/ping/synthetics';
+
+export interface PendingBlock {
+ status: 'pending' | 'loading';
+}
+
+export function isPendingBlock(data: unknown): data is PendingBlock {
+ return ['pending', 'loading'].some((s) => s === (data as PendingBlock)?.status);
+}
+export type StoreScreenshotBlock = ScreenshotBlockDoc | PendingBlock;
+export interface ScreenshotBlockCache {
+ [hash: string]: StoreScreenshotBlock;
+}
+
+export interface CacheHitCount {
+ hash: string;
+ hitTime: number;
+}
+
+export interface SyntheticsReducerState {
+ blocks: ScreenshotBlockCache;
+ cacheSize: number;
+ hitCount: CacheHitCount[];
+}
+
+export interface PutBlocksPayload {
+ blocks: ScreenshotBlockDoc[];
+}
+
+// this action denotes a set of blocks is required
+export const fetchBlocksAction = createAction('FETCH_BLOCKS');
+// this action denotes a request for a set of blocks is in flight
+export const setBlockLoadingAction = createAction('IN_FLIGHT_BLOCKS_ACTION');
+// block data has been received, and should be added to the store
+export const putBlocksAction = createAction('PUT_SCREENSHOT_BLOCKS');
+// updates the total size of the image blob data cached in the store
+export const putCacheSize = createAction('PUT_CACHE_SIZE');
+// keeps track of the most-requested blocks
+export const updateHitCountsAction = createAction('UPDATE_HIT_COUNTS');
+// reduce the cache size to the value in the action payload
+export const pruneCacheAction = createAction('PRUNE_SCREENSHOT_BLOCK_CACHE');
+
+const initialState: SyntheticsReducerState = {
+ blocks: {},
+ cacheSize: 0,
+ hitCount: [],
+};
+
+// using `any` here because `handleActions` is not set up well to handle the multi-type
+// nature of all the actions it supports. redux-actions is looking for new maintainers https://github.com/redux-utilities/redux-actions#looking-for-maintainers
+// and seems that we should move to something else like Redux Toolkit.
+export const syntheticsReducer = handleActions<
+ SyntheticsReducerState,
+ string[] & PutBlocksPayload & number
+>(
+ {
+ /**
+ * When removing blocks from the cache, we receive an action with a number.
+ * The number equates to the desired ceiling size of the cache. We then discard
+ * blocks, ordered by the least-requested. We continue dropping blocks until
+ * the newly-pruned size will be less than the ceiling supplied by the action.
+ */
+ [String(pruneCacheAction)]: (state, action: Action) => handlePruneAction(state, action),
+
+ /**
+ * Keep track of the least- and most-requested blocks, so when it is time to
+ * prune we keep the most commonly-used ones.
+ */
+ [String(updateHitCountsAction)]: (state, action: Action) =>
+ handleUpdateHitCountsAction(state, action),
+
+ [String(putCacheSize)]: (state, action: Action) => ({
+ ...state,
+ cacheSize: state.cacheSize + action.payload,
+ }),
+
+ [String(fetchBlocksAction)]: (state, action: Action) => ({
+ // increment hit counts
+ ...state,
+ blocks: {
+ ...state.blocks,
+ ...action.payload
+ // there's no need to overwrite existing blocks because the key
+ // is either storing a pending req or a cached result
+ .filter((b) => !state.blocks[b])
+ // convert the list of new hashes in the payload to an object that
+ // will combine with with the existing blocks cache
+ .reduce(
+ (acc, cur) => ({
+ ...acc,
+ [cur]: { status: 'pending' },
+ }),
+ {}
+ ),
+ },
+ }),
+
+ /**
+ * All hashes contained in the action payload have been requested, so we can
+ * indicate that they're loading. Subsequent requests will skip them.
+ */
+ [String(setBlockLoadingAction)]: (state, action: Action) => ({
+ ...state,
+ blocks: {
+ ...state.blocks,
+ ...action.payload.reduce(
+ (acc, cur) => ({
+ ...acc,
+ [cur]: { status: 'loading' },
+ }),
+ {}
+ ),
+ },
+ }),
+
+ [String(putBlocksAction)]: (state, action: Action) => ({
+ ...state,
+ blocks: {
+ ...state.blocks,
+ ...action.payload.blocks.reduce((acc, cur) => ({ ...acc, [cur.id]: cur }), {}),
+ },
+ }),
+ },
+ initialState
+);
+
+function handlePruneAction(state: SyntheticsReducerState, action: Action) {
+ const { blocks, hitCount } = state;
+ const hashesToPrune: string[] = [];
+ let sizeToRemove = 0;
+ let removeIndex = hitCount.length - 1;
+ while (sizeToRemove < action.payload && removeIndex >= 0) {
+ const { hash } = hitCount[removeIndex];
+ removeIndex--;
+ if (!blocks[hash]) continue;
+ const block = blocks[hash];
+ if (isScreenshotBlockDoc(block)) {
+ sizeToRemove += block.synthetics.blob.length;
+ hashesToPrune.push(hash);
+ }
+ }
+ for (const hash of hashesToPrune) {
+ delete blocks[hash];
+ }
+ return {
+ cacheSize: state.cacheSize - sizeToRemove,
+ blocks: { ...blocks },
+ hitCount: hitCount.slice(0, removeIndex + 1),
+ };
+}
+
+function handleUpdateHitCountsAction(state: SyntheticsReducerState, action: Action) {
+ const newHitCount = [...state.hitCount];
+ const hitTime = Date.now();
+ action.payload.forEach((hash) => {
+ const countItem = newHitCount.find((item) => item.hash === hash);
+ if (!countItem) {
+ newHitCount.push({ hash, hitTime });
+ } else {
+ countItem.hitTime = hitTime;
+ }
+ });
+ // sorts in descending order
+ newHitCount.sort((a, b) => b.hitTime - a.hitTime);
+ return {
+ ...state,
+ hitCount: newHitCount,
+ };
+}
diff --git a/x-pack/plugins/uptime/public/state/selectors/index.test.ts b/x-pack/plugins/uptime/public/state/selectors/index.test.ts
index e4094c72a6e10..520ebdac0c1e0 100644
--- a/x-pack/plugins/uptime/public/state/selectors/index.test.ts
+++ b/x-pack/plugins/uptime/public/state/selectors/index.test.ts
@@ -109,6 +109,11 @@ describe('state selectors', () => {
},
journeys: {},
networkEvents: {},
+ synthetics: {
+ blocks: {},
+ cacheSize: 0,
+ hitCount: [],
+ },
};
it('selects base path from state', () => {
diff --git a/x-pack/plugins/uptime/public/state/selectors/index.ts b/x-pack/plugins/uptime/public/state/selectors/index.ts
index 6c4ea8201398c..222687c78a868 100644
--- a/x-pack/plugins/uptime/public/state/selectors/index.ts
+++ b/x-pack/plugins/uptime/public/state/selectors/index.ts
@@ -97,3 +97,5 @@ export const monitorIdSelector = ({ ui: { monitorId } }: AppState) => monitorId;
export const journeySelector = ({ journeys }: AppState) => journeys;
export const networkEventsSelector = ({ networkEvents }: AppState) => networkEvents;
+
+export const syntheticsSelector = ({ synthetics }: AppState) => synthetics;
diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.test.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.test.ts
index 3d8bc04a10565..15cee91606e66 100644
--- a/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.test.ts
+++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.test.ts
@@ -12,6 +12,7 @@ describe('getJourneyScreenshot', () => {
it('returns screenshot data', async () => {
const screenshotResult = {
_id: 'id',
+ _index: 'index',
_source: {
synthetics: {
blob_mime: 'image/jpeg',
@@ -26,8 +27,14 @@ describe('getJourneyScreenshot', () => {
expect(
await getJourneyScreenshot({
uptimeEsClient: mockSearchResult([], {
- // @ts-expect-error incomplete search result
- step: { image: { hits: { hits: [screenshotResult] } } },
+ step: {
+ image: {
+ hits: {
+ total: 1,
+ hits: [screenshotResult],
+ },
+ },
+ },
}),
checkGroup: 'checkGroup',
stepIndex: 0,
@@ -48,6 +55,7 @@ describe('getJourneyScreenshot', () => {
it('returns ref data', async () => {
const screenshotRefResult = {
_id: 'id',
+ _index: 'index',
_source: {
'@timestamp': '123',
monitor: {
@@ -86,8 +94,7 @@ describe('getJourneyScreenshot', () => {
expect(
await getJourneyScreenshot({
uptimeEsClient: mockSearchResult([], {
- // @ts-expect-error incomplete search result
- step: { image: { hits: { hits: [screenshotRefResult] } } },
+ step: { image: { hits: { hits: [screenshotRefResult], total: 1 } } },
}),
checkGroup: 'checkGroup',
stepIndex: 0,
diff --git a/x-pack/plugins/uptime/server/rest_api/index.ts b/x-pack/plugins/uptime/server/rest_api/index.ts
index d4d0e13bd23db..8ae878669ba32 100644
--- a/x-pack/plugins/uptime/server/rest_api/index.ts
+++ b/x-pack/plugins/uptime/server/rest_api/index.ts
@@ -12,7 +12,7 @@ import {
createGetPingsRoute,
createJourneyRoute,
createJourneyScreenshotRoute,
- createJourneyScreenshotBlockRoute,
+ createJourneyScreenshotBlocksRoute,
} from './pings';
import { createGetDynamicSettingsRoute, createPostDynamicSettingsRoute } from './dynamic_settings';
import { createLogPageViewRoute } from './telemetry';
@@ -52,8 +52,8 @@ export const restApiRoutes: UMRestApiRouteFactory[] = [
createGetMonitorDurationRoute,
createJourneyRoute,
createJourneyScreenshotRoute,
- createJourneyScreenshotBlockRoute,
createNetworkEventsRoute,
createJourneyFailedStepsRoute,
createLastSuccessfulStepRoute,
+ createJourneyScreenshotBlocksRoute,
];
diff --git a/x-pack/plugins/uptime/server/rest_api/pings/index.ts b/x-pack/plugins/uptime/server/rest_api/pings/index.ts
index 45cd23dea42ed..0e1cc7baa9ad1 100644
--- a/x-pack/plugins/uptime/server/rest_api/pings/index.ts
+++ b/x-pack/plugins/uptime/server/rest_api/pings/index.ts
@@ -9,4 +9,4 @@ export { createGetPingsRoute } from './get_pings';
export { createGetPingHistogramRoute } from './get_ping_histogram';
export { createJourneyRoute } from './journeys';
export { createJourneyScreenshotRoute } from './journey_screenshots';
-export { createJourneyScreenshotBlockRoute } from './journey_screenshot_blocks';
+export { createJourneyScreenshotBlocksRoute } from './journey_screenshot_blocks';
diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.test.ts b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.test.ts
new file mode 100644
index 0000000000000..4909e2eb80108
--- /dev/null
+++ b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.test.ts
@@ -0,0 +1,81 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { createJourneyScreenshotBlocksRoute } from './journey_screenshot_blocks';
+
+describe('journey screenshot blocks route', () => {
+ let libs: unknown;
+ beforeEach(() => {
+ libs = {
+ uptimeEsClient: jest.fn(),
+ request: {
+ body: {
+ hashes: ['hash1', 'hash2'],
+ },
+ },
+ response: {
+ badRequest: jest.fn().mockReturnValue({ status: 400, message: 'Bad request.' }),
+ ok: jest.fn((responseData) => ({ ...responseData, status: 200, message: 'Ok' })),
+ notFound: jest.fn().mockReturnValue({ status: 404, message: 'Not found.' }),
+ },
+ };
+ });
+
+ it('returns status code 400 if hash list is invalid', async () => {
+ // @ts-expect-error incomplete implementation for testing
+ const route = createJourneyScreenshotBlocksRoute();
+
+ libs = Object.assign({}, libs, { request: { body: { hashes: undefined } } });
+
+ // @ts-expect-error incomplete implementation for testing
+ const response = await route.handler(libs);
+ expect(response.status).toBe(400);
+ });
+
+ it('returns status code 404 if result is empty set', async () => {
+ const route = createJourneyScreenshotBlocksRoute({
+ // @ts-expect-error incomplete implementation for testing
+ requests: {
+ getJourneyScreenshotBlocks: jest.fn().mockReturnValue([]),
+ },
+ });
+
+ // @ts-expect-error incomplete implementation for testing
+ expect((await route.handler(libs)).status).toBe(404);
+ });
+
+ it('returns blocks for request', async () => {
+ const responseData = [
+ {
+ id: 'hash1',
+ synthetics: {
+ blob: 'blob1',
+ blob_mime: 'image/jpeg',
+ },
+ },
+ {
+ id: 'hash2',
+ synthetics: {
+ blob: 'blob2',
+ blob_mime: 'image/jpeg',
+ },
+ },
+ ];
+ const route = createJourneyScreenshotBlocksRoute({
+ // @ts-expect-error incomplete implementation for testing
+ requests: {
+ getJourneyScreenshotBlocks: jest.fn().mockReturnValue(responseData),
+ },
+ });
+
+ // @ts-expect-error incomplete implementation for testing
+ const response = await route.handler(libs);
+ expect(response.status).toBe(200);
+ // @ts-expect-error incomplete implementation for testing
+ expect(response.body).toEqual(responseData);
+ });
+});
diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.ts b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.ts
index 63c2cfe7e2d48..3127c34590ef5 100644
--- a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.ts
+++ b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.ts
@@ -10,44 +10,38 @@ import { isRight } from 'fp-ts/lib/Either';
import { schema } from '@kbn/config-schema';
import { UMServerLibs } from '../../lib/lib';
import { UMRestApiRouteFactory } from '../types';
-import { ScreenshotBlockDoc } from '../../../common/runtime_types/ping/synthetics';
-export const createJourneyScreenshotBlockRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({
- method: 'GET',
+function isStringArray(data: unknown): data is string[] {
+ return isRight(t.array(t.string).decode(data));
+}
+
+export const createJourneyScreenshotBlocksRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({
+ method: 'POST',
path: '/api/uptime/journey/screenshot/block',
validate: {
+ body: schema.object({
+ hashes: schema.arrayOf(schema.string()),
+ }),
query: schema.object({
- hash: schema.oneOf([schema.string(), schema.arrayOf(schema.string())]),
_inspect: schema.maybe(schema.boolean()),
}),
},
handler: async ({ request, response, uptimeEsClient }) => {
- const { hash } = request.query;
+ const { hashes: blockIds } = request.body;
+
+ if (!isStringArray(blockIds)) return response.badRequest();
+
+ const result = await libs.requests.getJourneyScreenshotBlocks({
+ blockIds,
+ uptimeEsClient,
+ });
- const decoded = t.union([t.string, t.array(t.string)]).decode(hash);
- if (!isRight(decoded)) {
- return response.badRequest();
- }
- const { right: data } = decoded;
- let result: ScreenshotBlockDoc[];
- try {
- result = await libs.requests.getJourneyScreenshotBlocks({
- blockIds: Array.isArray(data) ? data : [data],
- uptimeEsClient,
- });
- } catch (e: unknown) {
- return response.custom({ statusCode: 500, body: { message: e } });
- }
if (result.length === 0) {
return response.notFound();
}
+
return response.ok({
body: result,
- headers: {
- // we can cache these blocks with extreme prejudice as they are inherently unchanging
- // when queried by ID, since the ID is the hash of the data
- 'Cache-Control': 'max-age=604800',
- },
});
},
});
diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.test.ts b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.test.ts
new file mode 100644
index 0000000000000..22aef54fa10bd
--- /dev/null
+++ b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.test.ts
@@ -0,0 +1,179 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { createJourneyScreenshotRoute } from './journey_screenshots';
+
+describe('journey screenshot route', () => {
+ let libs: unknown;
+ beforeEach(() => {
+ libs = {
+ uptimeEsClient: jest.fn(),
+ request: {
+ params: {
+ checkGroup: 'check_group',
+ stepIndex: 0,
+ },
+ },
+ response: {
+ ok: jest.fn((responseData) => ({ ...responseData, status: 200, message: 'Ok' })),
+ notFound: jest.fn().mockReturnValue({ status: 404, message: 'Not found.' }),
+ },
+ };
+ });
+
+ it('will 404 for missing screenshot', async () => {
+ const route = createJourneyScreenshotRoute({
+ // @ts-expect-error incomplete implementation for testing
+ requests: {
+ getJourneyScreenshot: jest.fn(),
+ },
+ });
+
+ // @ts-expect-error incomplete implementation for testing
+ expect(await route.handler(libs)).toMatchInlineSnapshot(`
+ Object {
+ "message": "Not found.",
+ "status": 404,
+ }
+ `);
+ });
+
+ it('returns screenshot ref', async () => {
+ const mock = {
+ '@timestamp': '123',
+ monitor: {
+ check_group: 'check_group',
+ },
+ screenshot_ref: {
+ width: 100,
+ height: 200,
+ blocks: [{ hash: 'hash', top: 0, left: 0, height: 10, width: 10 }],
+ },
+ synthetics: {
+ package_version: '1.0.0',
+ step: {
+ name: 'a step name',
+ index: 0,
+ },
+ type: 'step/screenshot_ref',
+ },
+ totalSteps: 3,
+ };
+
+ const route = createJourneyScreenshotRoute({
+ // @ts-expect-error incomplete implementation for testing
+ requests: {
+ getJourneyScreenshot: jest.fn().mockReturnValue(mock),
+ },
+ });
+
+ // @ts-expect-error incomplete implementation for testing
+ const response = await route.handler(libs);
+ expect(response.status).toBe(200);
+ // @ts-expect-error response doesn't match interface for testing
+ expect(response.headers).toMatchInlineSnapshot(`
+ Object {
+ "cache-control": "max-age=600",
+ "caption-name": "a step name",
+ "max-steps": "3",
+ }
+ `);
+ // @ts-expect-error response doesn't match interface for testing
+ expect(response.body.screenshotRef).toEqual(mock);
+ });
+
+ it('returns full screenshot blob', async () => {
+ const mock = {
+ synthetics: {
+ blob: 'a blob',
+ blob_mime: 'image/jpeg',
+ step: {
+ name: 'a step name',
+ },
+ type: 'step/screenshot',
+ },
+ totalSteps: 3,
+ };
+ const route = createJourneyScreenshotRoute({
+ // @ts-expect-error incomplete implementation for testing
+ requests: {
+ getJourneyScreenshot: jest.fn().mockReturnValue(mock),
+ },
+ });
+
+ // @ts-expect-error incomplete implementation for testing
+ expect(await route.handler(libs)).toMatchInlineSnapshot(`
+ Object {
+ "body": Object {
+ "data": Array [
+ 105,
+ 185,
+ 104,
+ ],
+ "type": "Buffer",
+ },
+ "headers": Object {
+ "cache-control": "max-age=600",
+ "caption-name": "a step name",
+ "content-type": "image/jpeg",
+ "max-steps": "3",
+ },
+ "message": "Ok",
+ "status": 200,
+ }
+ `);
+ });
+
+ it('defaults to png when mime is undefined', async () => {
+ const mock = {
+ synthetics: {
+ blob: 'a blob',
+ step: {
+ name: 'a step name',
+ },
+ type: 'step/screenshot',
+ },
+ };
+ const route = createJourneyScreenshotRoute({
+ // @ts-expect-error incomplete implementation for testing
+ requests: {
+ getJourneyScreenshot: jest.fn().mockReturnValue(mock),
+ },
+ });
+
+ // @ts-expect-error incomplete implementation for testing
+ const response = await route.handler(libs);
+
+ expect(response.status).toBe(200);
+ // @ts-expect-error incomplete implementation for testing
+ expect(response.headers['content-type']).toBe('image/png');
+ });
+
+ it('returns 404 for screenshot missing blob', async () => {
+ const route = createJourneyScreenshotRoute({
+ // @ts-expect-error incomplete implementation for testing
+ requests: {
+ getJourneyScreenshot: jest.fn().mockReturnValue({
+ synthetics: {
+ step: {
+ name: 'a step name',
+ },
+ type: 'step/screenshot',
+ },
+ }),
+ },
+ });
+
+ // @ts-expect-error incomplete implementation for testing
+ expect(await route.handler(libs)).toMatchInlineSnapshot(`
+ Object {
+ "message": "Not found.",
+ "status": 404,
+ }
+ `);
+ });
+});
diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts
index bd7cf6af4f843..5f0825279ecfa 100644
--- a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts
+++ b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts
@@ -6,11 +6,7 @@
*/
import { schema } from '@kbn/config-schema';
-import {
- isRefResult,
- isFullScreenshot,
- ScreenshotBlockDoc,
-} from '../../../common/runtime_types/ping/synthetics';
+import { isRefResult, isFullScreenshot } from '../../../common/runtime_types/ping/synthetics';
import { UMServerLibs } from '../../lib/lib';
import { ScreenshotReturnTypesUnion } from '../../lib/requests/get_journey_screenshot';
import { UMRestApiRouteFactory } from '../types';
@@ -39,22 +35,13 @@ export const createJourneyScreenshotRoute: UMRestApiRouteFactory = (libs: UMServ
handler: async ({ uptimeEsClient, request, response }) => {
const { checkGroup, stepIndex } = request.params;
- let result: ScreenshotReturnTypesUnion | null = null;
- try {
- result = await libs.requests.getJourneyScreenshot({
- uptimeEsClient,
- checkGroup,
- stepIndex,
- });
- } catch (e) {
- return response.customError({ body: { message: e }, statusCode: 500 });
- }
-
- if (isFullScreenshot(result)) {
- if (!result.synthetics.blob) {
- return response.notFound();
- }
+ const result: ScreenshotReturnTypesUnion | null = await libs.requests.getJourneyScreenshot({
+ uptimeEsClient,
+ checkGroup,
+ stepIndex,
+ });
+ if (isFullScreenshot(result) && typeof result.synthetics?.blob !== 'undefined') {
return response.ok({
body: Buffer.from(result.synthetics.blob, 'base64'),
headers: {
@@ -63,22 +50,11 @@ export const createJourneyScreenshotRoute: UMRestApiRouteFactory = (libs: UMServ
},
});
} else if (isRefResult(result)) {
- const blockIds = result.screenshot_ref.blocks.map(({ hash }) => hash);
- let blocks: ScreenshotBlockDoc[];
- try {
- blocks = await libs.requests.getJourneyScreenshotBlocks({
- uptimeEsClient,
- blockIds,
- });
- } catch (e: unknown) {
- return response.custom({ statusCode: 500, body: { message: e } });
- }
return response.ok({
body: {
screenshotRef: result,
- blocks,
},
- headers: getSharedHeaders(result.synthetics.step.name, result.totalSteps ?? 0),
+ headers: getSharedHeaders(result.synthetics.step.name, result.totalSteps),
});
}