diff --git a/x-pack/plugins/security_solution/public/endpoint_alerts/view/test_helpers/render_alert_page.tsx b/x-pack/plugins/security_solution/public/endpoint_alerts/view/test_helpers/render_alert_page.tsx index f52d854d986ff9..11ddc1f2529723 100644 --- a/x-pack/plugins/security_solution/public/endpoint_alerts/view/test_helpers/render_alert_page.tsx +++ b/x-pack/plugins/security_solution/public/endpoint_alerts/view/test_helpers/render_alert_page.tsx @@ -29,6 +29,7 @@ export const alertPageTestRender = () => { const depsStart = depsStartMock(); depsStart.data.ui.SearchBar.mockImplementation(() =>
); + const uiSettings = new Map(); return { store, @@ -47,7 +48,7 @@ export const alertPageTestRender = () => { */ return reactTestingLibrary.render( - + diff --git a/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts b/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts new file mode 100644 index 00000000000000..0cc116a85fa578 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { getFriendlyElapsedTime } from './date'; + +describe('date', () => { + describe('getFriendlyElapsedTime', () => { + const second = 1000; + const minute = second * 60; + const hour = minute * 60; + const day = hour * 24; + const week = day * 7; + const month = day * 30; + const year = day * 365; + + const initialTime = new Date('6/1/2020').getTime(); + + const oneSecond = new Date(initialTime + 1 * second).getTime(); + const oneMinute = new Date(initialTime + 1 * minute).getTime(); + const oneHour = new Date(initialTime + 1 * hour).getTime(); + const oneDay = new Date(initialTime + 1 * day).getTime(); + const oneWeek = new Date(initialTime + 1 * week).getTime(); + const oneMonth = new Date(initialTime + 1 * month).getTime(); + const oneYear = new Date(initialTime + 1 * year).getTime(); + + const almostAMinute = new Date(initialTime + 59.9 * second).getTime(); + const almostAnHour = new Date(initialTime + 59.9 * minute).getTime(); + const almostADay = new Date(initialTime + 23.9 * hour).getTime(); + const almostAWeek = new Date(initialTime + 6.9 * day).getTime(); + const almostAMonth = new Date(initialTime + 3.9 * week).getTime(); + const almostAYear = new Date(initialTime + 11.9 * month).getTime(); + const threeYears = new Date(initialTime + 3 * year).getTime(); + + it('should return the correct singular relative time', () => { + expect(getFriendlyElapsedTime(initialTime, oneSecond)).toEqual({ + duration: 1, + durationType: 'second', + }); + expect(getFriendlyElapsedTime(initialTime, oneMinute)).toEqual({ + duration: 1, + durationType: 'minute', + }); + expect(getFriendlyElapsedTime(initialTime, oneHour)).toEqual({ + duration: 1, + durationType: 'hour', + }); + expect(getFriendlyElapsedTime(initialTime, oneDay)).toEqual({ + duration: 1, + durationType: 'day', + }); + expect(getFriendlyElapsedTime(initialTime, oneWeek)).toEqual({ + duration: 1, + durationType: 'week', + }); + expect(getFriendlyElapsedTime(initialTime, oneMonth)).toEqual({ + duration: 1, + durationType: 'month', + }); + expect(getFriendlyElapsedTime(initialTime, oneYear)).toEqual({ + duration: 1, + durationType: 'year', + }); + }); + + it('should return the correct pluralized relative time', () => { + expect(getFriendlyElapsedTime(initialTime, almostAMinute)).toEqual({ + duration: 59, + durationType: 'seconds', + }); + expect(getFriendlyElapsedTime(initialTime, almostAnHour)).toEqual({ + duration: 59, + durationType: 'minutes', + }); + expect(getFriendlyElapsedTime(initialTime, almostADay)).toEqual({ + duration: 23, + durationType: 'hours', + }); + expect(getFriendlyElapsedTime(initialTime, almostAWeek)).toEqual({ + duration: 6, + durationType: 'days', + }); + expect(getFriendlyElapsedTime(initialTime, almostAMonth)).toEqual({ + duration: 3, + durationType: 'weeks', + }); + expect(getFriendlyElapsedTime(initialTime, almostAYear)).toEqual({ + duration: 11, + durationType: 'months', + }); + expect(getFriendlyElapsedTime(initialTime, threeYears)).toEqual({ + duration: 3, + durationType: 'years', + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/lib/date.ts b/x-pack/plugins/security_solution/public/resolver/lib/date.ts new file mode 100644 index 00000000000000..de0f9dcd7efbea --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/lib/date.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DurationDetails, DurationTypes } from '../types'; + +/* + * Given two unix timestamps, it will return an object containing the time difference and properly pluralized friendly version of the time difference. + * i.e. a time difference of 1000ms will yield => { duration: 1, durationType: 'second' } and 10000ms will yield => { duration: 10, durationType: 'seconds' } + * + */ +export const getFriendlyElapsedTime = ( + from: number | string, + to: number | string +): DurationDetails | null => { + const startTime = typeof from === 'number' ? from : parseInt(from, 10); + const endTime = typeof to === 'number' ? to : parseInt(to, 10); + const elapsedTimeInMs = endTime - startTime; + + if (Number.isNaN(elapsedTimeInMs)) { + return null; + } + + const second = 1000; + const minute = second * 60; + const hour = minute * 60; + const day = hour * 24; + const week = day * 7; + const month = day * 30; + const year = day * 365; + + let duration: number; + let singularType: DurationTypes; + let pluralType: DurationTypes; + switch (true) { + case elapsedTimeInMs >= year: + duration = elapsedTimeInMs / year; + singularType = 'year'; + pluralType = 'years'; + break; + case elapsedTimeInMs >= month: + duration = elapsedTimeInMs / month; + singularType = 'month'; + pluralType = 'months'; + break; + case elapsedTimeInMs >= week: + duration = elapsedTimeInMs / week; + singularType = 'week'; + pluralType = 'weeks'; + break; + case elapsedTimeInMs >= day: + duration = elapsedTimeInMs / day; + singularType = 'day'; + pluralType = 'days'; + break; + case elapsedTimeInMs >= hour: + duration = elapsedTimeInMs / hour; + singularType = 'hour'; + pluralType = 'hours'; + break; + case elapsedTimeInMs >= minute: + duration = elapsedTimeInMs / minute; + singularType = 'minute'; + pluralType = 'minutes'; + break; + case elapsedTimeInMs >= second: + duration = elapsedTimeInMs / second; + singularType = 'second'; + pluralType = 'seconds'; + break; + default: + duration = elapsedTimeInMs; + singularType = 'millisecond'; + pluralType = 'milliseconds'; + break; + } + + const durationType = duration > 1 ? pluralType : singularType; + return { duration: Math.floor(duration), durationType }; +}; diff --git a/x-pack/plugins/security_solution/public/resolver/store/camera/scaling_constants.ts b/x-pack/plugins/security_solution/public/resolver/store/camera/scaling_constants.ts index 243d8877a8b0d7..5e92290b4c97b4 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/camera/scaling_constants.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/camera/scaling_constants.ts @@ -12,12 +12,12 @@ export const minimum = 0.5; /** * The maximum allowed value for the camera scale. This is greatest scale that we will ever render something at. */ -export const maximum = 6; +export const maximum = 2; /** * The curve of the zoom function growth rate. The higher the scale factor is, the higher the zoom rate will be. */ -export const zoomCurveRate = 4; +export const zoomCurveRate = 2; /** * The size, in world units, of a 'nudge' as caused by clicking the up, right, down, or left panning buttons. diff --git a/x-pack/plugins/security_solution/public/resolver/store/camera/zooming.test.ts b/x-pack/plugins/security_solution/public/resolver/store/camera/zooming.test.ts index fb38c2f526e0b3..ff03b0baf01aaf 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/camera/zooming.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/camera/zooming.test.ts @@ -70,12 +70,12 @@ describe('zooming', () => { expect(actual).toMatchInlineSnapshot(` Object { "maximum": Array [ - 25.000000000000007, - 16.666666666666668, + 75, + 50, ], "minimum": Array [ - -25, - -16.666666666666668, + -75, + -50, ], } `); diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/__snapshots__/graphing.test.ts.snap b/x-pack/plugins/security_solution/public/resolver/store/data/__snapshots__/graphing.test.ts.snap index 00abc27b25a83d..f21d3b21068127 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/__snapshots__/graphing.test.ts.snap +++ b/x-pack/plugins/security_solution/public/resolver/store/data/__snapshots__/graphing.test.ts.snap @@ -35,136 +35,169 @@ Object { exports[`resolver graph layout when rendering two forks, and one fork has an extra long tine renders right 1`] = ` Object { "edgeLineSegments": Array [ - Array [ - Array [ - 0, - -0.8164965809277259, - ], - Array [ - 70.71067811865476, - -41.641325627314025, - ], - ], - Array [ - Array [ - -70.71067811865476, - -123.29098372008661, - ], - Array [ - 212.13203435596427, - 40.00833246545857, - ], - ], - Array [ - Array [ - -70.71067811865476, - -123.29098372008661, - ], - Array [ - 0, - -164.1158127664729, - ], - ], - Array [ - Array [ - 212.13203435596427, - 40.00833246545857, - ], - Array [ - 282.842712474619, - -0.8164965809277259, - ], - ], - Array [ - Array [ - 0, - -164.1158127664729, - ], - Array [ - 70.71067811865476, - -204.9406418128592, - ], - ], - Array [ - Array [ - 0, - -245.76547085924548, - ], - Array [ - 141.4213562373095, - -164.1158127664729, - ], - ], - Array [ - Array [ - 0, - -245.76547085924548, - ], - Array [ - 70.71067811865476, - -286.5902999056318, + Object { + "points": Array [ + Array [ + 0, + -0.8164965809277259, + ], + Array [ + 197.9898987322333, + -115.12601791080935, + ], ], - ], - Array [ - Array [ - 141.4213562373095, - -164.1158127664729, + }, + Object { + "points": Array [ + Array [ + 0, + -229.43553924069099, + ], + Array [ + 395.9797974644666, + -0.8164965809277259, + ], ], - Array [ - 212.13203435596427, - -204.9406418128592, + }, + Object { + "metadata": Object {}, + "points": Array [ + Array [ + 0, + -229.43553924069099, + ], + Array [ + 197.9898987322333, + -343.7450605705726, + ], ], - ], - Array [ - Array [ - 282.842712474619, - -0.8164965809277259, + }, + Object { + "metadata": Object {}, + "points": Array [ + Array [ + 395.9797974644666, + -0.8164965809277259, + ], + Array [ + 593.9696961966999, + -115.12601791080935, + ], ], - Array [ - 353.5533905932738, - -41.64132562731401, + }, + Object { + "points": Array [ + Array [ + 197.9898987322333, + -343.7450605705726, + ], + Array [ + 395.9797974644666, + -458.05458190045425, + ], ], - ], - Array [ - Array [ - 282.842712474619, - -82.4661546737003, + }, + Object { + "points": Array [ + Array [ + 296.98484809834997, + -515.2093425653951, + ], + Array [ + 494.9747468305833, + -400.8998212355134, + ], ], - Array [ - 424.26406871192853, - -0.8164965809277259, + }, + Object { + "metadata": Object {}, + "points": Array [ + Array [ + 296.98484809834997, + -515.2093425653951, + ], + Array [ + 494.9747468305833, + -629.5188638952767, + ], ], - ], - Array [ - Array [ - 282.842712474619, - -82.4661546737003, + }, + Object { + "metadata": Object {}, + "points": Array [ + Array [ + 494.9747468305833, + -400.8998212355134, + ], + Array [ + 692.9646455628166, + -515.2093425653951, + ], ], - Array [ - 353.5533905932738, - -123.29098372008661, + }, + Object { + "points": Array [ + Array [ + 593.9696961966999, + -115.12601791080935, + ], + Array [ + 791.9595949289333, + -229.43553924069096, + ], ], - ], - Array [ - Array [ - 424.26406871192853, - -0.8164965809277259, + }, + Object { + "points": Array [ + Array [ + 692.9646455628166, + -286.5902999056318, + ], + Array [ + 890.9545442950499, + -172.28077857575016, + ], ], - Array [ - 494.9747468305833, - -41.64132562731404, + }, + Object { + "metadata": Object {}, + "points": Array [ + Array [ + 692.9646455628166, + -286.5902999056318, + ], + Array [ + 890.9545442950499, + -400.89982123551346, + ], ], - ], - Array [ - Array [ - 494.9747468305833, - -41.64132562731404, + }, + Object { + "metadata": Object {}, + "points": Array [ + Array [ + 890.9545442950499, + -172.28077857575016, + ], + Array [ + 1088.9444430272833, + -286.5902999056318, + ], ], - Array [ - 636.3961030678928, - -123.2909837200866, + }, + Object { + "metadata": Object {}, + "points": Array [ + Array [ + 1088.9444430272833, + -286.5902999056318, + ], + Array [ + 1286.9343417595164, + -400.89982123551346, + ], ], - ], + }, ], "processNodePositions": Map { Object { @@ -198,8 +231,8 @@ Object { "unique_ppid": 0, }, } => Array [ - 0, - -164.1158127664729, + 197.9898987322333, + -343.7450605705726, ], Object { "@timestamp": 1582233383000, @@ -215,8 +248,8 @@ Object { "unique_ppid": 0, }, } => Array [ - 282.842712474619, - -0.8164965809277259, + 593.9696961966999, + -115.12601791080935, ], Object { "@timestamp": 1582233383000, @@ -232,8 +265,8 @@ Object { "unique_ppid": 1, }, } => Array [ - 70.71067811865476, - -286.5902999056318, + 494.9747468305833, + -629.5188638952767, ], Object { "@timestamp": 1582233383000, @@ -249,8 +282,8 @@ Object { "unique_ppid": 1, }, } => Array [ - 212.13203435596427, - -204.9406418128592, + 692.9646455628166, + -515.2093425653951, ], Object { "@timestamp": 1582233383000, @@ -266,8 +299,8 @@ Object { "unique_ppid": 2, }, } => Array [ - 353.5533905932738, - -123.29098372008661, + 890.9545442950499, + -400.89982123551346, ], Object { "@timestamp": 1582233383000, @@ -283,8 +316,8 @@ Object { "unique_ppid": 2, }, } => Array [ - 494.9747468305833, - -41.64132562731404, + 1088.9444430272833, + -286.5902999056318, ], Object { "@timestamp": 1582233383000, @@ -300,8 +333,8 @@ Object { "unique_ppid": 6, }, } => Array [ - 636.3961030678928, - -123.2909837200866, + 1286.9343417595164, + -400.89982123551346, ], }, } @@ -310,16 +343,19 @@ Object { exports[`resolver graph layout when rendering two nodes, one being the parent of the other renders right 1`] = ` Object { "edgeLineSegments": Array [ - Array [ - Array [ - 0, - -0.8164965809277259, - ], - Array [ - 141.4213562373095, - -82.46615467370032, + Object { + "metadata": Object {}, + "points": Array [ + Array [ + 0, + -0.8164965809277259, + ], + Array [ + 197.9898987322333, + -115.12601791080935, + ], ], - ], + }, ], "processNodePositions": Map { Object { @@ -353,8 +389,8 @@ Object { "unique_ppid": 0, }, } => Array [ - 141.4213562373095, - -82.46615467370032, + 197.9898987322333, + -115.12601791080935, ], }, } diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 6904107eeebb51..672b3fb2c72938 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -15,9 +15,10 @@ import { Matrix3, AdjacentProcessMap, Vector2, + EdgeLineMetadata, } from '../../types'; import { ResolverEvent } from '../../../../common/endpoint/types'; - +import { eventTimestamp } from '../../../../common/endpoint/models/event'; import { add as vector2Add, applyMatrix3 } from '../../lib/vector2'; import { isGraphableProcess, uniquePidForProcess } from '../../models/process_event'; import { @@ -27,8 +28,9 @@ import { size, levelOrder, } from '../../models/indexed_process_tree'; +import { getFriendlyElapsedTime } from '../../lib/date'; -const unit = 100; +const unit = 140; const distanceBetweenNodesInUnits = 2; export function isLoading(state: DataState) { @@ -155,6 +157,7 @@ function processEdgeLineSegments( ): EdgeLineSegment[] { const edgeLineSegments: EdgeLineSegment[] = []; for (const metadata of levelOrderWithWidths(indexedProcessTree, widths)) { + const edgeLineMetadata: EdgeLineMetadata = {}; /** * We only handle children, drawing lines back to their parents. The root has no parent, so we skip it */ @@ -173,6 +176,13 @@ function processEdgeLineSegments( throw new Error(); } + const parentTime = eventTimestamp(parent); + const processTime = eventTimestamp(process); + if (parentTime && processTime) { + const elapsedTime = getFriendlyElapsedTime(parentTime, processTime); + if (elapsedTime) edgeLineMetadata.elapsedTime = elapsedTime; + } + /** * The point halfway between the parent and child on the y axis, we sometimes have a hard angle here in the edge line */ @@ -188,14 +198,17 @@ function processEdgeLineSegments( * | | * B C */ - const lineFromProcessToMidwayLine: EdgeLineSegment = [[position[0], midwayY], position]; + const lineFromProcessToMidwayLine: EdgeLineSegment = { + points: [[position[0], midwayY], position], + metadata: edgeLineMetadata, + }; const siblings = indexedProcessTreeChildren(indexedProcessTree, parent); const isFirstChild = process === siblings[0]; if (metadata.isOnlyChild) { // add a single line segment directly from parent to child. We don't do the 'pitchfork' in this case. - edgeLineSegments.push([parentPosition, position]); + edgeLineSegments.push({ points: [parentPosition, position], metadata: edgeLineMetadata }); } else if (isFirstChild) { /** * If the parent has multiple children, we draw the 'midway' line, and the line from the @@ -211,28 +224,29 @@ function processEdgeLineSegments( */ const { firstChildWidth, lastChildWidth } = metadata; - const lineFromParentToMidwayLine: EdgeLineSegment = [ - parentPosition, - [parentPosition[0], midwayY], - ]; + const lineFromParentToMidwayLine: EdgeLineSegment = { + points: [parentPosition, [parentPosition[0], midwayY]], + }; const widthOfMidline = parentWidth - firstChildWidth / 2 - lastChildWidth / 2; const minX = parentWidth / -2 + firstChildWidth / 2; const maxX = minX + widthOfMidline; - const midwayLine: EdgeLineSegment = [ - [ - // Position line relative to the parent's x component - parentPosition[0] + minX, - midwayY, - ], - [ - // Position line relative to the parent's x component - parentPosition[0] + maxX, - midwayY, + const midwayLine: EdgeLineSegment = { + points: [ + [ + // Position line relative to the parent's x component + parentPosition[0] + minX, + midwayY, + ], + [ + // Position line relative to the parent's x component + parentPosition[0] + maxX, + midwayY, + ], ], - ]; + }; edgeLineSegments.push( /* line from parent to midway line */ @@ -347,7 +361,7 @@ function processPositions( */ positions.set(process, [0, 0]); } else { - const { process, parent, width, parentWidth } = metadata; + const { process, parent, isOnlyChild, width, parentWidth } = metadata; // Reinit counters when parent changes if (lastProcessedParentNode !== parent) { @@ -387,7 +401,14 @@ function processPositions( /** * The y axis gains `-distanceBetweenNodes` as we move down the screen 1 unit at a time. */ - const position = vector2Add([xOffset, -distanceBetweenNodes], parentPosition); + let yDistanceBetweenNodes = -distanceBetweenNodes; + + if (!isOnlyChild) { + // Make space on leaves to show elapsed time + yDistanceBetweenNodes *= 2; + } + + const position = vector2Add([xOffset, yDistanceBetweenNodes], parentPosition); positions.set(process, position); @@ -470,10 +491,20 @@ export const processNodePositionsAndEdgeLineSegments = createSelector( } for (const edgeLineSegment of edgeLineSegments) { - const transformedSegment = []; - for (const point of edgeLineSegment) { - transformedSegment.push(applyMatrix3(point, isometricTransformMatrix)); - } + const { + points: [startPoint, endPoint], + metadata, + } = edgeLineSegment; + + const transformedSegment: EdgeLineSegment = { + points: [ + applyMatrix3(startPoint, isometricTransformMatrix), + applyMatrix3(endPoint, isometricTransformMatrix), + ], + }; + + if (metadata) transformedSegment.metadata = metadata; + transformedEdgeLineSegments.push(transformedSegment); } diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index ecdd032a6c1d7c..765b2b2a26adac 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -241,10 +241,50 @@ export type ProcessWidths = Map; * Map of ProcessEvents (representing process nodes) to their positions. Calculated by `processPositions` */ export type ProcessPositions = Map; + +export type DurationTypes = + | 'millisecond' + | 'milliseconds' + | 'second' + | 'seconds' + | 'minute' + | 'minutes' + | 'hour' + | 'hours' + | 'day' + | 'days' + | 'week' + | 'weeks' + | 'month' + | 'months' + | 'year' + | 'years'; + +/** + * duration value and description string + */ +export interface DurationDetails { + duration: number; + durationType: DurationTypes; +} +/** + * Values shared between two vertices joined by an edge line. + */ +export interface EdgeLineMetadata { + elapsedTime?: DurationDetails; +} +/** + * A tuple of 2 vector2 points forming a polyline. Used to connect process nodes in the graph. + */ +export type EdgeLinePoints = Vector2[]; + /** - * An array of vectors2 forming an polyline. Used to connect process nodes in the graph. + * Edge line components including the points joining the edgeline and any optional associated metadata */ -export type EdgeLineSegment = Vector2[]; +export interface EdgeLineSegment { + points: EdgeLinePoints; + metadata?: EdgeLineMetadata; +} /** * Used to provide precalculated info from `widthsOfProcessSubtrees`. These 'width' values are used in the layout of the graph. diff --git a/x-pack/plugins/security_solution/public/resolver/view/defs.tsx b/x-pack/plugins/security_solution/public/resolver/view/assets.tsx similarity index 63% rename from x-pack/plugins/security_solution/public/resolver/view/defs.tsx rename to x-pack/plugins/security_solution/public/resolver/view/assets.tsx index d70ee7bc235875..150ab3d93a8c7f 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/defs.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/assets.tsx @@ -5,81 +5,40 @@ */ import React, { memo } from 'react'; -import { saturate } from 'polished'; - -import { - htmlIdGenerator, - euiPaletteForTemperature, - euiPaletteForStatus, - colorPalette, -} from '@elastic/eui'; +import euiThemeAmsterdamDark from '@elastic/eui/dist/eui_theme_amsterdam_dark.json'; +import euiThemeAmsterdamLight from '@elastic/eui/dist/eui_theme_amsterdam_light.json'; +import { htmlIdGenerator, ButtonColor } from '@elastic/eui'; import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { useUiSetting } from '../../common/lib/kibana'; +import { DEFAULT_DARK_MODE } from '../../../common/constants'; -/** - * Generating from `colorPalette` function: This could potentially - * pick up a palette shift and decouple from raw hex - */ -const [euiColorEmptyShade, , , , , euiColor85Shade, euiColorFullShade] = colorPalette( - ['#ffffff', '#000000'], - 7 -); - -/** - * Base Colors - sourced from EUI - */ -const resolverPalette: Record = { - temperatures: euiPaletteForTemperature(7), - statii: euiPaletteForStatus(7), - fullShade: euiColorFullShade, - emptyShade: euiColorEmptyShade, -}; - -/** - * Defines colors by semantics like so: - * `danger`, `attention`, `enabled`, `disabled` - * Or by function like: - * `colorBlindBackground`, `subMenuForeground` - */ type ResolverColorNames = - | 'ok' - | 'empty' + | 'descriptionText' | 'full' - | 'warning' - | 'strokeBehindEmpty' + | 'graphControls' + | 'graphControlsBackground' | 'resolverBackground' - | 'runningProcessStart' - | 'runningProcessEnd' - | 'runningTriggerStart' - | 'runningTriggerEnd' - | 'activeNoWarning' - | 'activeWarning' - | 'fullLabelBackground' - | 'inertDescription' - | 'labelBackgroundTerminatedProcess' - | 'labelBackgroundTerminatedTrigger' - | 'labelBackgroundRunningProcess' - | 'labelBackgroundRunningTrigger'; + | 'resolverEdge' + | 'resolverEdgeText'; -export const NamedColors: Record = { - ok: saturate(0.5, resolverPalette.temperatures[0]), - empty: euiColorEmptyShade, - full: euiColorFullShade, - strokeBehindEmpty: euiColor85Shade, - warning: resolverPalette.statii[3], - resolverBackground: euiColorFullShade, - runningProcessStart: '#006BB4', - runningProcessEnd: '#017D73', - runningTriggerStart: '#BD281E', - runningTriggerEnd: '#DD0A73', - activeNoWarning: '#0078FF', - activeWarning: '#C61F38', - fullLabelBackground: '#3B3C41', - labelBackgroundTerminatedProcess: '#8A96A8', - labelBackgroundTerminatedTrigger: '#8A96A8', - labelBackgroundRunningProcess: '#8A96A8', - labelBackgroundRunningTrigger: '#8A96A8', - inertDescription: '#747474', -}; +type ColorMap = Record; +interface NodeStyleConfig { + backingFill: string; + cubeSymbol: string; + descriptionFill: string; + descriptionText: string; + isLabelFilled: boolean; + labelButtonFill: ButtonColor; + strokeColor: string; +} + +export interface NodeStyleMap { + runningProcessCube: NodeStyleConfig; + runningTriggerCube: NodeStyleConfig; + terminatedProcessCube: NodeStyleConfig; + terminatedTriggerCube: NodeStyleConfig; +} const idGenerator = htmlIdGenerator(); @@ -97,32 +56,9 @@ export const PaintServerIds = { * PaintServers: Where color palettes, grandients, patterns and other similar concerns * are exposed to the component */ -const PaintServers = memo(() => ( + +const PaintServers = memo(({ isDarkMode }: { isDarkMode: boolean }) => ( <> - - - - - - - - ( + {isDarkMode ? ( + <> + + + + + + + + + + ) : ( + <> + + + + + + + + + + )} )); @@ -167,7 +156,7 @@ export const SymbolIds = { /** * Defs entries that define shapes, masks and other spatial elements */ -const SymbolsAndShapes = memo(() => ( +const SymbolsAndShapes = memo(({ isDarkMode }: { isDarkMode: boolean }) => ( <> ( {'Terminated Trigger Process'} - + {isDarkMode && ( + + )} + {!isDarkMode && ( + + )} ( {'resolver active backing'} @@ -383,14 +383,17 @@ SymbolsAndShapes.displayName = 'SymbolsAndShapes'; * 2. Separation of concerns between creative assets and more functional areas of the app * 3. `` elements can be handled by compositor (faster) */ -const SymbolDefinitionsComponent = memo(({ className }: { className?: string }) => ( - - - - - - -)); +const SymbolDefinitionsComponent = memo(({ className }: { className?: string }) => { + const isDarkMode = useUiSetting(DEFAULT_DARK_MODE); + return ( + + + + + + + ); +}); SymbolDefinitionsComponent.displayName = 'SymbolDefinitions'; @@ -401,3 +404,88 @@ export const SymbolDefinitions = styled(SymbolDefinitionsComponent)` width: 0; height: 0; `; + +export const useResolverTheme = (): { colorMap: ColorMap; nodeAssets: NodeStyleMap } => { + const isDarkMode = useUiSetting(DEFAULT_DARK_MODE); + const theme = isDarkMode ? euiThemeAmsterdamDark : euiThemeAmsterdamLight; + + const getThemedOption = (lightOption: string, darkOption: string): string => { + return isDarkMode ? darkOption : lightOption; + }; + + const colorMap = { + descriptionText: theme.euiColorDarkestShade, + full: theme.euiColorFullShade, + graphControls: theme.euiColorDarkestShade, + graphControlsBackground: theme.euiColorEmptyShade, + processBackingFill: `${theme.euiColorPrimary}${getThemedOption('0F', '1F')}`, // Add opacity 0F = 6% , 1F = 12% + resolverBackground: theme.euiColorEmptyShade, + resolverEdge: getThemedOption(theme.euiColorLightestShade, theme.euiColorLightShade), + resolverEdgeText: getThemedOption(theme.euiColorDarkShade, theme.euiColorFullShade), + triggerBackingFill: `${theme.euiColorDanger}${getThemedOption('0F', '1F')}`, + }; + + const nodeAssets: NodeStyleMap = { + runningProcessCube: { + backingFill: colorMap.processBackingFill, + cubeSymbol: `#${SymbolIds.runningProcessCube}`, + descriptionFill: colorMap.descriptionText, + descriptionText: i18n.translate('xpack.securitySolution.endpoint.resolver.runningProcess', { + defaultMessage: 'Running Process', + }), + isLabelFilled: true, + labelButtonFill: 'primary', + strokeColor: theme.euiColorPrimary, + }, + runningTriggerCube: { + backingFill: colorMap.triggerBackingFill, + cubeSymbol: `#${SymbolIds.runningTriggerCube}`, + descriptionFill: colorMap.descriptionText, + descriptionText: i18n.translate('xpack.securitySolution.endpoint.resolver.runningTrigger', { + defaultMessage: 'Running Trigger', + }), + isLabelFilled: true, + labelButtonFill: 'danger', + strokeColor: theme.euiColorDanger, + }, + terminatedProcessCube: { + backingFill: colorMap.processBackingFill, + cubeSymbol: `#${SymbolIds.terminatedProcessCube}`, + descriptionFill: colorMap.descriptionText, + descriptionText: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.terminatedProcess', + { + defaultMessage: 'Terminated Process', + } + ), + isLabelFilled: false, + labelButtonFill: 'primary', + strokeColor: `${theme.euiColorPrimary}33`, // 33 = 20% opacity + }, + terminatedTriggerCube: { + backingFill: colorMap.triggerBackingFill, + cubeSymbol: `#${SymbolIds.terminatedTriggerCube}`, + descriptionFill: colorMap.descriptionText, + descriptionText: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.terminatedTrigger', + { + defaultMessage: 'Terminated Trigger', + } + ), + isLabelFilled: false, + labelButtonFill: 'danger', + strokeColor: `${theme.euiColorDanger}33`, + }, + }; + + return { colorMap, nodeAssets }; +}; + +export const calculateResolverFontSize = ( + magFactorX: number, + minFontSize: number, + slopeOfFontScale: number +): number => { + const fontSizeAdjustmentForScale = magFactorX > 1 ? slopeOfFontScale * (magFactorX - 1) : 0; + return minFontSize + fontSizeAdjustmentForScale; +}; diff --git a/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx b/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx index 2192422b7d31d4..4eccb4f5602209 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx @@ -6,8 +6,48 @@ import React from 'react'; import styled from 'styled-components'; +import { FormattedMessage } from '@kbn/i18n/react'; import { applyMatrix3, distance, angle } from '../lib/vector2'; -import { Vector2, Matrix3 } from '../types'; +import { Vector2, Matrix3, EdgeLineMetadata } from '../types'; +import { useResolverTheme, calculateResolverFontSize } from './assets'; + +interface StyledEdgeLine { + readonly resolverEdgeColor: string; + readonly magFactorX: number; +} + +const StyledEdgeLine = styled.div` + position: absolute; + height: ${(props) => { + return `${calculateResolverFontSize(props.magFactorX, 12, 8.5)}px`; + }}; + background-color: ${(props) => props.resolverEdgeColor}; +`; + +interface StyledElapsedTime { + readonly backgroundColor: string; + readonly leftPct: number; + readonly scaledTypeSize: number; + readonly textColor: string; +} + +const StyledElapsedTime = styled.div` + background-color: ${(props) => props.backgroundColor}; + color: ${(props) => props.textColor}; + font-size: ${(props) => `${props.scaledTypeSize}px`}; + font-weight: bold; + max-width: 75%; + overflow: hidden; + position: absolute; + text-overflow: ellipsis; + top: 50%; + white-space: nowrap; + left: ${(props) => `${props.leftPct}%`}; + padding: 6px 8px; + border-radius: 999px; // generate pill shape + transform: translate(-50%, -50%) rotateX(35deg); + user-select: none; +`; /** * A placeholder line segment view that connects process nodes. @@ -15,6 +55,7 @@ import { Vector2, Matrix3 } from '../types'; const EdgeLineComponent = React.memo( ({ className, + edgeLineMetadata, startPosition, endPosition, projectionMatrix, @@ -23,6 +64,10 @@ const EdgeLineComponent = React.memo( * A className string provided by `styled` */ className?: string; + /** + * Time elapsed betweeen process nodes + */ + edgeLineMetadata?: EdgeLineMetadata; /** * The postion of first point in the line segment. In 'world' coordinates. */ @@ -42,12 +87,16 @@ const EdgeLineComponent = React.memo( */ const screenStart = applyMatrix3(startPosition, projectionMatrix); const screenEnd = applyMatrix3(endPosition, projectionMatrix); + const [magFactorX] = projectionMatrix; + const { colorMap } = useResolverTheme(); + const elapsedTime = edgeLineMetadata?.elapsedTime; /** * We render the line using a short, long, `div` element. The length of this `div` * should be the same as the distance between the start and end points. */ const length = distance(screenStart, screenEnd); + const scaledTypeSize = calculateResolverFontSize(magFactorX, 10, 7.5); const style = { left: `${screenStart[0]}px`, @@ -65,16 +114,47 @@ const EdgeLineComponent = React.memo( */ transform: `translateY(-50%) rotateZ(${angle(screenStart, screenEnd)}rad)`, }; - return
; + + let elapsedTimeLeftPosPct = 50; + + /** + * Calculates a fractional offset from 0 -> 5% as magFactorX decreases from 1 to a min of .5 + */ + if (magFactorX < 1) { + const fractionalOffset = (1 / magFactorX) * ((1 - magFactorX) * 10); + elapsedTimeLeftPosPct += fractionalOffset; + } + + return ( + + {elapsedTime && ( + + + + )} + + ); } ); EdgeLineComponent.displayName = 'EdgeLine'; -export const EdgeLine = styled(EdgeLineComponent)` - position: absolute; - height: 3px; - background-color: #d4d4d4; - color: #333333; - contain: strict; -`; +export const EdgeLine = EdgeLineComponent; diff --git a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx index 544dd7143ca280..67c091627741ad 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx @@ -13,7 +13,43 @@ import { useSelector, useDispatch } from 'react-redux'; import { SideEffectContext } from './side_effect_context'; import { ResolverAction, Vector2 } from '../types'; import * as selectors from '../store/selectors'; +import { useResolverTheme } from './assets'; +interface StyledGraphControls { + graphControlsBackground: string; + graphControlsIconColor: string; +} + +const StyledGraphControls = styled.div` + position: absolute; + top: 5px; + right: 5px; + background-color: ${(props) => props.graphControlsBackground}; + color: ${(props) => props.graphControlsIconColor}; + + .zoom-controls { + display: flex; + flex-direction: column; + align-items: center; + padding: 5px 0px; + + .zoom-slider { + width: 20px; + height: 150px; + margin: 5px 0px 2px 0px; + + input[type='range'] { + width: 150px; + height: 20px; + transform-origin: 75px 75px; + transform: rotate(-90deg); + } + } + } + .panning-controls { + text-align: center; + } +`; /** * Controls for zooming, panning, and centering in Resolver */ @@ -29,6 +65,7 @@ const GraphControlsComponent = React.memo( const dispatch: (action: ResolverAction) => unknown = useDispatch(); const scalingFactor = useSelector(selectors.scalingFactor); const { timestamp } = useContext(SideEffectContext); + const { colorMap } = useResolverTheme(); const handleZoomAmountChange = useCallback( (event: React.ChangeEvent | React.MouseEvent) => { @@ -83,7 +120,11 @@ const GraphControlsComponent = React.memo( }, [dispatch, timestamp]); return ( -
+
-
+
); } ); GraphControlsComponent.displayName = 'GraphControlsComponent'; -export const GraphControls = styled(GraphControlsComponent)` - position: absolute; - top: 5px; - right: 5px; - background-color: #d4d4d4; - color: #333333; - - .zoom-controls { - display: flex; - flex-direction: column; - align-items: center; - padding: 5px 0px; - - .zoom-slider { - width: 20px; - height: 150px; - margin: 5px 0px 2px 0px; - - input[type='range'] { - width: 150px; - height: 20px; - transform-origin: 75px 75px; - transform: rotate(-90deg); - } - } - } - .panning-controls { - text-align: center; - } -`; +export const GraphControls = GraphControlsComponent; diff --git a/x-pack/plugins/security_solution/public/resolver/view/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/index.tsx index 9b336d6916bdf3..0e15cd5c4e1dac 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/index.tsx @@ -15,115 +15,16 @@ import { Panel } from './panel'; import { GraphControls } from './graph_controls'; import { ProcessEventDot } from './process_event_dot'; import { useCamera } from './use_camera'; -import { SymbolDefinitions, NamedColors } from './defs'; +import { SymbolDefinitions, useResolverTheme } from './assets'; import { ResolverAction } from '../types'; import { ResolverEvent } from '../../../common/endpoint/types'; import * as eventModel from '../../../common/endpoint/models/event'; -const StyledPanel = styled(Panel)` - position: absolute; - left: 1em; - top: 1em; - max-height: calc(100% - 2em); - overflow: auto; - width: 25em; - max-width: 50%; -`; +interface StyledResolver { + backgroundColor: string; +} -const StyledResolverContainer = styled.div` - display: flex; - flex-grow: 1; - contain: layout; -`; - -const bgColor = NamedColors.resolverBackground; - -export const Resolver = styled( - React.memo(function Resolver({ - className, - selectedEvent, - }: { - className?: string; - selectedEvent?: ResolverEvent; - }) { - const { processNodePositions, edgeLineSegments } = useSelector( - selectors.processNodePositionsAndEdgeLineSegments - ); - - const dispatch: (action: ResolverAction) => unknown = useDispatch(); - const { processToAdjacencyMap } = useSelector(selectors.processAdjacencies); - const relatedEventsStats = useSelector(selectors.relatedEventsStats); - const { projectionMatrix, ref, onMouseDown } = useCamera(); - const isLoading = useSelector(selectors.isLoading); - const hasError = useSelector(selectors.hasError); - const activeDescendantId = useSelector(selectors.uiActiveDescendantId); - - useLayoutEffect(() => { - dispatch({ - type: 'userChangedSelectedEvent', - payload: { selectedEvent }, - }); - }, [dispatch, selectedEvent]); - - return ( -
- {isLoading ? ( -
- -
- ) : hasError ? ( -
-
- {' '} - -
-
- ) : ( - - {edgeLineSegments.map(([startPosition, endPosition], index) => ( - - ))} - {[...processNodePositions].map(([processEvent, position], index) => { - const adjacentNodeMap = processToAdjacencyMap.get(processEvent); - if (!adjacentNodeMap) { - // This should never happen - throw new Error('Issue calculating adjacency node map.'); - } - return ( - - ); - })} - - )} - - - -
- ); - }) -)` +const StyledResolver = styled.div` /** * Take up all availble space */ @@ -147,5 +48,112 @@ export const Resolver = styled( */ overflow: hidden; contain: strict; - background-color: ${bgColor}; + background-color: ${(props) => props.backgroundColor}; `; + +const StyledPanel = styled(Panel)` + position: absolute; + left: 1em; + top: 1em; + max-height: calc(100% - 2em); + overflow: auto; + width: 25em; + max-width: 50%; +`; + +const StyledResolverContainer = styled.div` + display: flex; + flex-grow: 1; + contain: layout; +`; + +export const Resolver = React.memo(function Resolver({ + className, + selectedEvent, +}: { + className?: string; + selectedEvent?: ResolverEvent; +}) { + const { processNodePositions, edgeLineSegments } = useSelector( + selectors.processNodePositionsAndEdgeLineSegments + ); + + const dispatch: (action: ResolverAction) => unknown = useDispatch(); + const { processToAdjacencyMap } = useSelector(selectors.processAdjacencies); + const relatedEventsStats = useSelector(selectors.relatedEventsStats); + const { projectionMatrix, ref, onMouseDown } = useCamera(); + const isLoading = useSelector(selectors.isLoading); + const hasError = useSelector(selectors.hasError); + const activeDescendantId = useSelector(selectors.uiActiveDescendantId); + const { colorMap } = useResolverTheme(); + + useLayoutEffect(() => { + dispatch({ + type: 'userChangedSelectedEvent', + payload: { selectedEvent }, + }); + }, [dispatch, selectedEvent]); + + return ( + + {isLoading ? ( +
+ +
+ ) : hasError ? ( +
+
+ {' '} + +
+
+ ) : ( + + {edgeLineSegments.map(({ points: [startPosition, endPosition], metadata }, index) => ( + + ))} + {[...processNodePositions].map(([processEvent, position], index) => { + const adjacentNodeMap = processToAdjacencyMap.get(processEvent); + if (!adjacentNodeMap) { + // This should never happen + throw new Error('Issue calculating adjacency node map.'); + } + return ( + + ); + })} + + )} + + + +
+ ); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 03903b722427ae..7b463f0bed26af 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -9,6 +9,7 @@ import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { htmlIdGenerator, + EuiButton, EuiI18nNumber, EuiKeyboardAccessible, EuiFlexGroup, @@ -18,48 +19,13 @@ import { useSelector } from 'react-redux'; import { NodeSubMenu, subMenuAssets } from './submenu'; import { applyMatrix3 } from '../lib/vector2'; import { Vector2, Matrix3, AdjacentProcessMap, ResolverProcessType } from '../types'; -import { SymbolIds, NamedColors } from './defs'; +import { SymbolIds, useResolverTheme, NodeStyleMap, calculateResolverFontSize } from './assets'; import { ResolverEvent, ResolverNodeStats } from '../../../common/endpoint/types'; import { useResolverDispatch } from './use_resolver_dispatch'; import * as eventModel from '../../../common/endpoint/models/event'; import * as processModel from '../models/process_event'; import * as selectors from '../store/selectors'; -const nodeAssets = { - runningProcessCube: { - cubeSymbol: `#${SymbolIds.runningProcessCube}`, - labelBackground: NamedColors.labelBackgroundRunningProcess, - descriptionFill: NamedColors.empty, - descriptionText: i18n.translate('xpack.securitySolution.endpoint.resolver.runningProcess', { - defaultMessage: 'Running Process', - }), - }, - runningTriggerCube: { - cubeSymbol: `#${SymbolIds.runningTriggerCube}`, - labelBackground: NamedColors.labelBackgroundRunningTrigger, - descriptionFill: NamedColors.empty, - descriptionText: i18n.translate('xpack.securitySolution.endpoint.resolver.runningTrigger', { - defaultMessage: 'Running Trigger', - }), - }, - terminatedProcessCube: { - cubeSymbol: `#${SymbolIds.terminatedProcessCube}`, - labelBackground: NamedColors.labelBackgroundTerminatedProcess, - descriptionFill: NamedColors.empty, - descriptionText: i18n.translate('xpack.securitySolution.endpoint.resolver.terminatedProcess', { - defaultMessage: 'Terminated Process', - }), - }, - terminatedTriggerCube: { - cubeSymbol: `#${SymbolIds.terminatedTriggerCube}`, - labelBackground: NamedColors.labelBackgroundTerminatedTrigger, - descriptionFill: NamedColors.empty, - descriptionText: i18n.translate('xpack.securitySolution.endpoint.resolver.terminatedTrigger', { - defaultMessage: 'Terminated Trigger', - }), - }, -}; - /** * Take a gross `schemaName` and return a beautiful translated one. */ @@ -232,6 +198,47 @@ const getDisplayName: (schemaName: string) => string = function nameInSchemaToDi ); }; +interface StyledActionsContainer { + readonly color: string; + readonly fontSize: number; + readonly topPct: number; +} + +const StyledActionsContainer = styled.div` + background-color: transparent; + color: ${(props) => props.color}; + display: flex; + flex-flow: column; + font-size: ${(props) => `${props.fontSize}px`}; + left: 20.9%; + line-height: 140%; + padding: 0.25rem 0 0 0.1rem; + position: absolute; + top: ${(props) => `${props.topPct}%`}; + width: auto; +`; + +interface StyledDescriptionText { + readonly backgroundColor: string; + readonly color: string; + readonly isDisplaying: boolean; +} + +const StyledDescriptionText = styled.div` + background-color: ${(props) => props.backgroundColor}; + color: ${(props) => props.color}; + display: ${(props) => (props.isDisplaying ? 'block' : 'none')}; + font-size: 0.8rem; + font-weight: bold; + letter-spacing: -0.01px; + line-height: 1; + margin: 0; + padding: 4px 0 0 2px; + text-align: left; + text-transform: uppercase; + width: fit-content; +`; + /** * An artifact that represents a process node and the things associated with it in the Resolver */ @@ -281,16 +288,48 @@ const ProcessEventDotComponents = React.memo( const activeDescendantId = useSelector(selectors.uiActiveDescendantId); const selectedDescendantId = useSelector(selectors.uiSelectedDescendantId); - const logicalProcessNodeViewWidth = 360; - const logicalProcessNodeViewHeight = 120; + const isShowingEventActions = magFactorX > 0.8; + const isShowingDescriptionText = magFactorX >= 0.55; + + /** + * As the resolver zooms and buttons and text change visibility, we look to keep the overall container properly vertically aligned + */ + const actionalButtonsBaseTopOffset = 5; + let actionableButtonsTopOffset; + switch (true) { + case isShowingEventActions: + actionableButtonsTopOffset = actionalButtonsBaseTopOffset + 3.5 * magFactorX; + break; + case isShowingDescriptionText: + actionableButtonsTopOffset = actionalButtonsBaseTopOffset + magFactorX; + break; + default: + actionableButtonsTopOffset = actionalButtonsBaseTopOffset + 21 * magFactorX; + break; + } + /** * The `left` and `top` values represent the 'center' point of the process node. * Since the view has content to the left and above the 'center' point, offset the * position to accomodate for that. This aligns the logical center of the process node * with the correct position on the map. */ - const processNodeViewXOffset = -0.172413 * logicalProcessNodeViewWidth * magFactorX; - const processNodeViewYOffset = -0.73684 * logicalProcessNodeViewHeight * magFactorX; + + const logicalProcessNodeViewWidth = 360; + const logicalProcessNodeViewHeight = 120; + + /** + * As the scale changes and button visibility toggles on the graph, these offsets help scale to keep the nodes centered on the edge + */ + const nodeXOffsetValue = isShowingEventActions + ? -0.147413 + : -0.147413 - (magFactorX - 0.5) * 0.08; + const nodeYOffsetValue = isShowingEventActions + ? -0.53684 + : -0.53684 + (-magFactorX * 0.2 * (1 - magFactorX)) / magFactorX; + + const processNodeViewXOffset = nodeXOffsetValue * logicalProcessNodeViewWidth * magFactorX; + const processNodeViewYOffset = nodeYOffsetValue * logicalProcessNodeViewHeight * magFactorX; const nodeViewportStyle = useMemo( () => ({ @@ -310,14 +349,12 @@ const ProcessEventDotComponents = React.memo( * 18.75 : The smallest readable font size at which labels/descriptions can be read. Font size will not scale below this. * 12.5 : A 'slope' at which the font size will scale w.r.t. to zoom level otherwise */ - const minimumFontSize = 18.75; - const slopeOfFontScale = 12.5; - const fontSizeAdjustmentForScale = magFactorX > 1 ? slopeOfFontScale * (magFactorX - 1) : 0; - const scaledTypeSize = minimumFontSize + fontSizeAdjustmentForScale; + const scaledTypeSize = calculateResolverFontSize(magFactorX, 18.75, 12.5); const markerBaseSize = 15; const markerSize = markerBaseSize; - const markerPositionOffset = -markerBaseSize / 2; + const markerPositionYOffset = -markerBaseSize / 2 - 4; + const markerPositionXOffset = -markerBaseSize / 2 - 4; /** * An element that should be animated when the node is clicked. @@ -333,7 +370,15 @@ const ProcessEventDotComponents = React.memo( }) | null; } = React.createRef(); - const { cubeSymbol, labelBackground, descriptionText } = nodeAssets[nodeType(event)]; + const { colorMap, nodeAssets } = useResolverTheme(); + const { + backingFill, + cubeSymbol, + descriptionText, + isLabelFilled, + labelButtonFill, + strokeColor, + } = nodeAssets[nodeType(event)]; const resolverNodeIdGenerator = useMemo(() => htmlIdGenerator('resolverNode'), []); const nodeId = useMemo(() => resolverNodeIdGenerator(selfId), [ @@ -388,27 +433,33 @@ const ProcessEventDotComponents = React.memo( * e.g. "10 DNS", "230 File" */ const relatedEventOptions = useMemo(() => { + const relatedStatsList = []; + if (!relatedEventsStats) { // Return an empty set of options if there are no stats to report return []; } // If we have entries to show, map them into options to display in the selectable list - return Object.entries(relatedEventsStats.events.byCategory).map(([category, total]) => { - const displayName = getDisplayName(category); - return { - prefix: , - optionTitle: `${displayName}`, - action: () => { - dispatch({ - type: 'userSelectedRelatedEventCategory', - payload: { - subject: event, - category, - }, - }); - }, - }; - }); + for (const category in relatedEventsStats.events.byCategory) { + if (Object.hasOwnProperty.call(relatedEventsStats.events.byCategory, category)) { + const total = relatedEventsStats.events.byCategory[category]; + const displayName = getDisplayName(category); + relatedStatsList.push({ + prefix: , + optionTitle: `${displayName}`, + action: () => { + dispatch({ + type: 'userSelectedRelatedEventCategory', + payload: { + subject: event, + category, + }, + }); + }, + }); + } + } + return relatedStatsList; }, [relatedEventsStats, dispatch, event]); const relatedEventStatusOrOptions = (() => { @@ -459,8 +510,10 @@ const ProcessEventDotComponents = React.memo( -
-
{descriptionText} -
+
= 2 ? 'euiButton' : 'euiButton euiButton--small'} - data-test-subject="nodeLabel" - id={labelId} style={{ - backgroundColor: labelBackground, - padding: '.15rem 0', - textAlign: 'center', - maxWidth: '20rem', - minWidth: '12rem', - width: '60%', - overflow: 'hidden', - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - contain: 'content', - margin: '.25rem 0 .35rem 0', + backgroundColor: colorMap.resolverBackground, + alignSelf: 'flex-start', + padding: 0, }} > - - - {eventModel.eventName(event)} + + + + {eventModel.eventName(event)} + - +
- {magFactorX >= 2 && ( - - - - - - - - - )} -
+ + + + + + + + +
); @@ -594,16 +641,24 @@ export const ProcessEventDot = styled(ProcessEventDotComponents)` & .backing { stroke-dasharray: 500; stroke-dashoffset: 500; + fill-opacity: 0; } + &:hover:not([aria-current]) .backing { + transition-property: fill-opacity; + transition-duration: 0.25s; + fill-opacity: 1; // actual color opacity handled in the fill hex + } + &[aria-current] .backing { transition-property: stroke-dashoffset; transition-duration: 1s; stroke-dashoffset: 0; } - & .related-dropdown { - width: 4.5em; + & .euiButton { + width: fit-content; } + & .euiSelectableList-bordered { border-top-right-radius: 0px; border-top-left-radius: 0px; @@ -619,7 +674,7 @@ export const ProcessEventDot = styled(ProcessEventDotComponents)` } `; -const processTypeToCube: Record = { +const processTypeToCube: Record = { processCreated: 'runningProcessCube', processRan: 'runningProcessCube', processTerminated: 'terminatedProcessCube', @@ -628,7 +683,7 @@ const processTypeToCube: Record = unknownEvent: 'runningProcessCube', }; -function nodeType(processEvent: ResolverEvent): keyof typeof nodeAssets { +function nodeType(processEvent: ResolverEvent): keyof NodeStyleMap { const processType = processModel.eventType(processEvent); if (processType in processTypeToCube) { diff --git a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx index 6f69725c8677a9..861c170b8b0b8f 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import React, { ReactNode, useState, useMemo, useCallback } from 'react'; -import { EuiSelectable, EuiButton } from '@elastic/eui'; +import { EuiSelectable, EuiButton, EuiPopover, ButtonColor, htmlIdGenerator } from '@elastic/eui'; import styled from 'styled-components'; /** @@ -35,7 +35,7 @@ export const subMenuAssets = { }), }, }; - +const idGenerator = htmlIdGenerator(); interface ResolverSubmenuOption { optionTitle: string; action: () => unknown; @@ -44,6 +44,10 @@ interface ResolverSubmenuOption { export type ResolverSubmenuOptionList = ResolverSubmenuOption[] | string; +const OptionListItem = styled.div` + width: 175px; +`; + const OptionList = React.memo( ({ subMenuOptions, @@ -81,7 +85,7 @@ const OptionList = React.memo( listProps={{ showIcons: true, bordered: true }} isLoading={isLoading} > - {(list) => list} + {(list) => {list}} ), [isLoading, options] @@ -98,11 +102,18 @@ OptionList.displayName = 'OptionList'; */ const NodeSubMenuComponents = React.memo( ({ + buttonBorderColor, menuTitle, menuAction, optionsWithActions, className, - }: { menuTitle: string; className?: string; menuAction: () => unknown } & { + }: { + menuTitle: string; + className?: string; + menuAction?: () => unknown; + buttonBorderColor: ButtonColor; + buttonFill: string; + } & { optionsWithActions?: ResolverSubmenuOptionList | string | undefined; }) => { const [menuIsOpen, setMenuOpen] = useState(false); @@ -126,6 +137,9 @@ const NodeSubMenuComponents = React.memo( [menuAction] ); + const closePopover = useCallback(() => setMenuOpen(false), []); + const popoverId = idGenerator('submenu-popover'); + const isMenuLoading = optionsWithActions === 'waitingForRelatedEventData'; if (!optionsWithActions) { @@ -135,7 +149,12 @@ const NodeSubMenuComponents = React.memo( */ return (
- + {menuTitle}
@@ -145,23 +164,35 @@ const NodeSubMenuComponents = React.memo( * When called with a set of `optionsWithActions`: * Render with a panel of options that appear when the menu host button is clicked */ + + const submenuPopoverButton = ( + + {menuTitle} + + ); + return (
- - {menuTitle} - - {menuIsOpen && typeof optionsWithActions === 'object' && ( - - )} + {menuIsOpen && typeof optionsWithActions === 'object' && ( + + )} +
); } @@ -170,11 +201,29 @@ const NodeSubMenuComponents = React.memo( NodeSubMenuComponents.displayName = 'NodeSubMenu'; export const NodeSubMenu = styled(NodeSubMenuComponents)` - margin: 0; + margin: 2px 0 0 0; padding: 0; border: none; display: flex; flex-flow: column; + + & .euiButton { + background-color: ${(props) => props.buttonFill}; + border-color: ${(props) => props.buttonBorderColor}; + border-style: solid; + border-width: 1px; + + &:hover, + &:active, + &:focus { + background-color: ${(props) => props.buttonFill}; + } + } + + & .euiPopover__anchor { + display: flex; + } + &.is-open .euiButton { border-bottom-left-radius: 0; border-bottom-right-radius: 0; diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx index 4f66322a247a68..8ed9f00d51af8b 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx @@ -138,11 +138,11 @@ describe('useCamera on an unpainted element', () => { it('should zoom in', () => { expect(projectionMatrix).toMatchInlineSnapshot(` Array [ - 1.0635255481707058, + 1.0292841801261479, 0, 400, 0, - -1.0635255481707058, + -1.0292841801261479, 300, 0, 0,