[Resolver] Improve simulator. Add more click-through tests and panel tests. (#74601)
### Improved the simulator. * Replace `mapStateTransitions` with `map`. The usage and interface are the same, but `map` is not dependent on redux state. This will work for parts of the app that don't use redux (aka EUI). `map` also forces any `AutoSizer` instances used by EUI to show their full contents. `AutoSizer` works but it doesn't behave as expected in JSDOM. With this hack in place, we can bypass `AutoSizer`. Going forward, we should make sure to use something other than `EuiSelectable` for the dropdowns * Removed the `connectEnzymeWrapperAndStore` test helper. The new `map` simulator method doesn't rely on redux so we no longer need this explicit sync. * The simulator can receive a memory history instance. This allows tests to pass in a precreated / controlled memory instance. Useful for testing the query string. This design is not final. Instead we could have an 'intiialHistorySearch' parameter that sets the query string on instantiation as well as 'pushHistory' and 'replaceHistory' methods? * `findInDom` is now called `domNodes`. * `processNodeElementLooksSelected` and `processNodeElementLooksUnselected` are gone. Instead use `selectedProcessNode` and `unselectedProcessNode` to find the wrappers and assert that they wrappers contain the nodes you are interested in. * Added `processNodeSubmenu` method that gets the submenu that comes up when you click the events button on a process node. * Added `nodeListElement` method. This returns the list of nodes that shows up in the panel. Name is not final. * Added `nodeListItems` method. This returns the list item elements in the node list. Name is not final. * Added `nodeListNodeLinks` method. This returns the links in the items in the node list. Name is not final. * Added `nodeDetailElement` method. This gets the element that contains details about a node. Name is not final. * Added `nodeDetailBreadcrumbNodeListLink` method. Returns the link rendered in the breadcrumbs of the node detail view. Takes the user to the node list. Name is not final. * Added `nodeDetailViewTitle` method. This returns the title of the node detail view. Name is not final. * Added `nodeDetailDescriptionListEntries` method. This returns an entries list of the details in the node detail view. Name is not final * Added `resolveWrapper` method. Pass this a function that returns a `ReactWrapper`. The method will evaluate the returned wrapper after each event loop and return it once it isn't empty. ### Improved our mocks * We had a DataAccessLayer and ResolverTree mock named 'one_ancestor_two_children` that actually had no ancestors. Renamed them to `no_ancestors_two_children`. * New DataAccessLayer mock called `noAncestorsTwoChildrenWithRelatedEventsOnOrigin` ### Added new 'clickthrough' suite test * Added new test in the 'clickthrough' suite that asserts that a user can click the 'related events' button on a node and see the list of related event categories in the submenu. ### Improved the Resolver event model * Added `timestampAsDateSafeVersion` to the event model. This gets a `Date` object for the timestamp. (We still need make it clear that this model is ResolverSpecific) ### New `urlSearch` test helper. Use `urlSearch` when testing Resolver's interaction with the browser location. It calculates the expected 'search' value based on some Resolver specific parameters. * Use this to calculate a URL and then populate the memory history with this URL. This will allow you to see if Resolver loads correctly based on the URL state. * Use this to calculate the expected URL based on Resolver's current state. ### Added new 'panel' test * If Resolver is loaded with a url search parameter that selects a node, the node's details are shown in the panel. * When a history.push occurs that sets a search parameter that selects a node, the details of that node are shown. * Check that the url search is updated when the user interacts with the panel * Check that the panel shows the correct details for a node. (except for the timestamp. See TODO) ### Changed `data-test-subj`s * Removed `resolver:panel`. This was used on a wrapper element that we expect to remove soon. * Added `resolver:node-detail:breadcrumbs:node-list-link` for the buttons in the breadcrumb in the panel. * Added `resolver:node-detail:title` for the title element in the node detail view. * Added `resolver:node-detail:entry-title` and `resolver:node-detail:entry-description` for the details shown about a process in the node detail view. * Added `resolver:node-list:node-link`. This is the link shown for each node in the node list. * added `resolver:node-list:item` to each list item in the node list view. ### Removed dead code * `map.tsx` wasn't being used. It was renamed but the old version wasn't deleted. ### Improved the node detail view * Show the timestamp for a node's process event even if the timestamp is the unix epoch. Note: this is technically a bug fix but the bug is very obscure. * Show the PID for a node's process event when the PID is 0. Note: this is a bug fix.
This commit is contained in:
parent
489b134927
commit
bd8761ee15
|
@ -55,6 +55,25 @@ export function timestampSafeVersion(event: SafeResolverEvent): string | undefin
|
|||
: firstNonNullValue(event?.['@timestamp']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `@timestamp` for the event, as a `Date` object.
|
||||
* If `@timestamp` couldn't be parsed as a `Date`, returns `undefined`.
|
||||
*/
|
||||
export function timestampAsDateSafeVersion(event: SafeResolverEvent): Date | undefined {
|
||||
const value = timestampSafeVersion(event);
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
// Check if the date is valid
|
||||
if (isFinite(date.getTime())) {
|
||||
return date;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function eventTimestamp(event: ResolverEvent): string | undefined | number {
|
||||
if (isLegacyEvent(event)) {
|
||||
return event.endgame.timestamp_utc;
|
||||
|
|
|
@ -15,12 +15,14 @@ export function mockEndpointEvent({
|
|||
parentEntityId,
|
||||
timestamp,
|
||||
lifecycleType,
|
||||
pid = 0,
|
||||
}: {
|
||||
entityID: string;
|
||||
name: string;
|
||||
parentEntityId?: string;
|
||||
timestamp: number;
|
||||
lifecycleType?: string;
|
||||
pid?: number;
|
||||
}): EndpointEvent {
|
||||
return {
|
||||
'@timestamp': timestamp,
|
||||
|
@ -45,7 +47,7 @@ export function mockEndpointEvent({
|
|||
executable: 'executable',
|
||||
args: 'args',
|
||||
name,
|
||||
pid: 0,
|
||||
pid,
|
||||
hash: {
|
||||
md5: 'hash.md5',
|
||||
},
|
||||
|
|
|
@ -175,18 +175,21 @@ export function mockTreeWithNoAncestorsAnd2Children({
|
|||
secondChildID: string;
|
||||
}): ResolverTree {
|
||||
const origin: ResolverEvent = mockEndpointEvent({
|
||||
pid: 0,
|
||||
entityID: originID,
|
||||
name: 'c',
|
||||
parentEntityId: 'none',
|
||||
timestamp: 0,
|
||||
});
|
||||
const firstChild: ResolverEvent = mockEndpointEvent({
|
||||
pid: 1,
|
||||
entityID: firstChildID,
|
||||
name: 'd',
|
||||
parentEntityId: originID,
|
||||
timestamp: 1,
|
||||
});
|
||||
const secondChild: ResolverEvent = mockEndpointEvent({
|
||||
pid: 2,
|
||||
entityID: secondChildID,
|
||||
name: 'e',
|
||||
parentEntityId: originID,
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { Store } from 'redux';
|
||||
import { ReactWrapper } from 'enzyme';
|
||||
|
||||
/**
|
||||
* We use the full-DOM emulation mode of `enzyme` via `mount`. Even though we use `react-redux`, `enzyme`
|
||||
* does not update the DOM after state transitions. This subscribes to the `redux` store and after any state
|
||||
* transition it asks `enzyme` to update the DOM to match the React state.
|
||||
*/
|
||||
export function connectEnzymeWrapperAndStore(store: Store<unknown>, wrapper: ReactWrapper): void {
|
||||
store.subscribe(() => {
|
||||
// See https://enzymejs.github.io/enzyme/docs/api/ReactWrapper/update.html
|
||||
return wrapper.update();
|
||||
});
|
||||
}
|
|
@ -10,7 +10,6 @@ import { mount, ReactWrapper } from 'enzyme';
|
|||
import { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history';
|
||||
import { CoreStart } from '../../../../../../../src/core/public';
|
||||
import { coreMock } from '../../../../../../../src/core/public/mocks';
|
||||
import { connectEnzymeWrapperAndStore } from '../connect_enzyme_wrapper_and_store';
|
||||
import { spyMiddlewareFactory } from '../spy_middleware_factory';
|
||||
import { resolverMiddlewareFactory } from '../../store/middleware';
|
||||
import { resolverReducer } from '../../store/reducer';
|
||||
|
@ -48,6 +47,7 @@ export class Simulator {
|
|||
dataAccessLayer,
|
||||
resolverComponentInstanceID,
|
||||
databaseDocumentID,
|
||||
history,
|
||||
}: {
|
||||
/**
|
||||
* A (mock) data access layer that will be used to create the Resolver store.
|
||||
|
@ -61,6 +61,7 @@ export class Simulator {
|
|||
* a databaseDocumentID to pass to Resolver. Resolver will use this in requests to the mock data layer.
|
||||
*/
|
||||
databaseDocumentID?: string;
|
||||
history?: HistoryPackageHistoryInterface<never>;
|
||||
}) {
|
||||
this.resolverComponentInstanceID = resolverComponentInstanceID;
|
||||
// create the spy middleware (for debugging tests)
|
||||
|
@ -79,8 +80,9 @@ export class Simulator {
|
|||
// Create a redux store w/ the top level Resolver reducer and the enhancer that includes the Resolver middleware and the `spyMiddleware`
|
||||
this.store = createStore(resolverReducer, middlewareEnhancer);
|
||||
|
||||
// Create a fake 'history' instance that Resolver will use to read and write query string values
|
||||
this.history = createMemoryHistory();
|
||||
// If needed, create a fake 'history' instance.
|
||||
// Resolver will use to read and write query string values.
|
||||
this.history = history ?? createMemoryHistory();
|
||||
|
||||
// Used for `KibanaContextProvider`
|
||||
const coreStart: CoreStart = coreMock.createStart();
|
||||
|
@ -95,9 +97,6 @@ export class Simulator {
|
|||
databaseDocumentID={databaseDocumentID}
|
||||
/>
|
||||
);
|
||||
|
||||
// Update the enzyme wrapper after each state transition
|
||||
connectEnzymeWrapperAndStore(this.store, this.wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -112,6 +111,16 @@ export class Simulator {
|
|||
return this.spyMiddleware.debugActions();
|
||||
}
|
||||
|
||||
/**
|
||||
* EUI uses a component called `AutoSizer` that won't render its children unless it has sufficient size.
|
||||
* This forces any `AutoSizer` instances to have a large size.
|
||||
*/
|
||||
private forceAutoSizerOpen() {
|
||||
this.wrapper
|
||||
.find('AutoSizer')
|
||||
.forEach((wrapper) => wrapper.setState({ width: 10000, height: 10000 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield the result of `mapper` over and over, once per event-loop cycle.
|
||||
* After 10 times, quit.
|
||||
|
@ -124,6 +133,7 @@ export class Simulator {
|
|||
yield mapper();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
this.forceAutoSizerOpen();
|
||||
this.wrapper.update();
|
||||
resolve();
|
||||
}, 0);
|
||||
|
@ -174,6 +184,13 @@ export class Simulator {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The items in the submenu that is opened by expanding a node in the map.
|
||||
*/
|
||||
public processNodeSubmenuItems(): ReactWrapper {
|
||||
return this.domNodes('[data-test-subj="resolver:map:node-submenu-item"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the selected node query string values.
|
||||
*/
|
||||
|
@ -206,38 +223,38 @@ export class Simulator {
|
|||
}
|
||||
|
||||
/**
|
||||
* An element with a list of all nodes.
|
||||
* The titles of the links that select a node in the node list view.
|
||||
*/
|
||||
public nodeListElement(): ReactWrapper {
|
||||
return this.domNodes('[data-test-subj="resolver:node-list"]');
|
||||
public nodeListNodeLinkText(): ReactWrapper {
|
||||
return this.domNodes('[data-test-subj="resolver:node-list:node-link:title"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the items in the node list (the default panel view.)
|
||||
* The icons in the links that select a node in the node list view.
|
||||
*/
|
||||
public nodeListItems(): ReactWrapper {
|
||||
return this.domNodes('[data-test-subj="resolver:node-list:item"]');
|
||||
public nodeListNodeLinkIcons(): ReactWrapper {
|
||||
return this.domNodes('[data-test-subj="resolver:node-list:node-link:icon"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* The element containing the details for the selected node.
|
||||
* Link rendered in the breadcrumbs of the node detail view. Takes the user to the node list.
|
||||
*/
|
||||
public nodeDetailElement(): ReactWrapper {
|
||||
return this.domNodes('[data-test-subj="resolver:node-detail"]');
|
||||
public nodeDetailBreadcrumbNodeListLink(): ReactWrapper {
|
||||
return this.domNodes('[data-test-subj="resolver:node-detail:breadcrumbs:node-list-link"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* The details of the selected node are shown in a description list. This returns the title elements of the description list.
|
||||
* The title element for the node detail view.
|
||||
*/
|
||||
private nodeDetailEntryTitle(): ReactWrapper {
|
||||
return this.domNodes('[data-test-subj="resolver:node-detail:entry-title"]');
|
||||
public nodeDetailViewTitle(): ReactWrapper {
|
||||
return this.domNodes('[data-test-subj="resolver:node-detail:title"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* The details of the selected node are shown in a description list. This returns the description elements of the description list.
|
||||
* The icon element for the node detail title.
|
||||
*/
|
||||
private nodeDetailEntryDescription(): ReactWrapper {
|
||||
return this.domNodes('[data-test-subj="resolver:node-detail:entry-description"]');
|
||||
public nodeDetailViewTitleIcon(): ReactWrapper {
|
||||
return this.domNodes('[data-test-subj="resolver:node-detail:title-icon"]');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -253,8 +270,14 @@ export class Simulator {
|
|||
* The titles and descriptions (as text) from the node detail panel.
|
||||
*/
|
||||
public nodeDetailDescriptionListEntries(): Array<[string, string]> {
|
||||
const titles = this.nodeDetailEntryTitle();
|
||||
const descriptions = this.nodeDetailEntryDescription();
|
||||
/**
|
||||
* The details of the selected node are shown in a description list. This returns the title elements of the description list.
|
||||
*/
|
||||
const titles = this.domNodes('[data-test-subj="resolver:node-detail:entry-title"]');
|
||||
/**
|
||||
* The details of the selected node are shown in a description list. This returns the description elements of the description list.
|
||||
*/
|
||||
const descriptions = this.domNodes('[data-test-subj="resolver:node-detail:entry-description"]');
|
||||
const entries: Array<[string, string]> = [];
|
||||
for (let index = 0; index < Math.min(titles.length, descriptions.length); index++) {
|
||||
const title = titles.at(index).text();
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
interface Options {
|
||||
/**
|
||||
* The entity_id of the selected node.
|
||||
*/
|
||||
selectedEntityID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the expected URL search based on options.
|
||||
*/
|
||||
export function urlSearch(resolverComponentInstanceID: string, options?: Options): string {
|
||||
if (!options) {
|
||||
return '';
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
if (options.selectedEntityID !== undefined) {
|
||||
params.set(`resolver-${resolverComponentInstanceID}-id`, options.selectedEntityID);
|
||||
}
|
||||
return params.toString();
|
||||
}
|
|
@ -4,6 +4,8 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
/* eslint-disable react/display-name */
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import euiThemeAmsterdamDark from '@elastic/eui/dist/eui_theme_amsterdam_dark.json';
|
||||
import euiThemeAmsterdamLight from '@elastic/eui/dist/eui_theme_amsterdam_light.json';
|
||||
|
@ -11,7 +13,7 @@ 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';
|
||||
import { DEFAULT_DARK_MODE as defaultDarkMode } from '../../../common/constants';
|
||||
import { ResolverProcessType } from '../types';
|
||||
|
||||
type ResolverColorNames =
|
||||
|
@ -141,8 +143,6 @@ const PaintServers = memo(({ isDarkMode }: { isDarkMode: boolean }) => (
|
|||
</>
|
||||
));
|
||||
|
||||
PaintServers.displayName = 'PaintServers';
|
||||
|
||||
/**
|
||||
* Ids of symbols to be linked by <use> elements
|
||||
*/
|
||||
|
@ -376,8 +376,6 @@ const SymbolsAndShapes = memo(({ isDarkMode }: { isDarkMode: boolean }) => (
|
|||
</>
|
||||
));
|
||||
|
||||
SymbolsAndShapes.displayName = 'SymbolsAndShapes';
|
||||
|
||||
/**
|
||||
* This `<defs>` element is used to define the reusable assets for the Resolver
|
||||
* It confers several advantages, including but not limited to:
|
||||
|
@ -386,7 +384,7 @@ SymbolsAndShapes.displayName = 'SymbolsAndShapes';
|
|||
* 3. `<use>` elements can be handled by compositor (faster)
|
||||
*/
|
||||
const SymbolDefinitionsComponent = memo(({ className }: { className?: string }) => {
|
||||
const isDarkMode = useUiSetting<boolean>(DEFAULT_DARK_MODE);
|
||||
const isDarkMode = useUiSetting<boolean>(defaultDarkMode);
|
||||
return (
|
||||
<svg className={className}>
|
||||
<defs>
|
||||
|
@ -397,8 +395,6 @@ const SymbolDefinitionsComponent = memo(({ className }: { className?: string })
|
|||
);
|
||||
});
|
||||
|
||||
SymbolDefinitionsComponent.displayName = 'SymbolDefinitions';
|
||||
|
||||
export const SymbolDefinitions = styled(SymbolDefinitionsComponent)`
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
|
@ -424,7 +420,7 @@ export const useResolverTheme = (): {
|
|||
nodeAssets: NodeStyleMap;
|
||||
cubeAssetsForNode: (isProcessTerimnated: boolean, isProcessTrigger: boolean) => NodeStyleConfig;
|
||||
} => {
|
||||
const isDarkMode = useUiSetting<boolean>(DEFAULT_DARK_MODE);
|
||||
const isDarkMode = useUiSetting<boolean>(defaultDarkMode);
|
||||
const theme = isDarkMode ? euiThemeAmsterdamDark : euiThemeAmsterdamLight;
|
||||
|
||||
const getThemedOption = (lightOption: string, darkOption: string): string => {
|
||||
|
|
|
@ -71,8 +71,9 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children',
|
|||
});
|
||||
});
|
||||
|
||||
it(`should show the node list`, async () => {
|
||||
await expect(simulator.map(() => simulator.nodeListElement().length)).toYieldEqualTo(1);
|
||||
it(`should show links to the 3 nodes (with icons) in the node list.`, async () => {
|
||||
await expect(simulator.map(() => simulator.nodeListNodeLinkText().length)).toYieldEqualTo(3);
|
||||
await expect(simulator.map(() => simulator.nodeListNodeLinkIcons().length)).toYieldEqualTo(3);
|
||||
});
|
||||
|
||||
describe("when the second child node's first button has been clicked", () => {
|
||||
|
@ -152,5 +153,20 @@ describe('Resolver, when analyzing a tree that has two related events for the or
|
|||
relatedEventButtons: 1,
|
||||
});
|
||||
});
|
||||
describe('when the related events button is clicked', () => {
|
||||
beforeEach(async () => {
|
||||
const button = await simulator.resolveWrapper(() =>
|
||||
simulator.processNodeRelatedEventButton(entityIDs.origin)
|
||||
);
|
||||
if (button) {
|
||||
button.simulate('click');
|
||||
}
|
||||
});
|
||||
it('should open the submenu', async () => {
|
||||
await expect(
|
||||
simulator.map(() => simulator.processNodeSubmenuItems().map((node) => node.text()))
|
||||
).toYieldEqualTo(['2 registry']);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,129 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
/* eslint-disable react/display-name */
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useEffectOnce } from 'react-use';
|
||||
import { EuiLoadingSpinner } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import * as selectors from '../store/selectors';
|
||||
import { EdgeLine } from './edge_line';
|
||||
import { GraphControls } from './graph_controls';
|
||||
import { ProcessEventDot } from './process_event_dot';
|
||||
import { useCamera } from './use_camera';
|
||||
import { SymbolDefinitions, useResolverTheme } from './assets';
|
||||
import { useStateSyncingActions } from './use_state_syncing_actions';
|
||||
import { useResolverQueryParams } from './use_resolver_query_params';
|
||||
import { StyledMapContainer, StyledPanel, GraphContainer } from './styles';
|
||||
import { entityIDSafeVersion } from '../../../common/endpoint/models/event';
|
||||
import { SideEffectContext } from './side_effect_context';
|
||||
|
||||
/**
|
||||
* The highest level connected Resolver component. Needs a `Provider` in its ancestry to work.
|
||||
*/
|
||||
export const ResolverMap = React.memo(function ({
|
||||
className,
|
||||
databaseDocumentID,
|
||||
resolverComponentInstanceID,
|
||||
}: {
|
||||
/**
|
||||
* Used by `styled-components`.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* The `_id` value of an event in ES.
|
||||
* Used as the origin of the Resolver graph.
|
||||
*/
|
||||
databaseDocumentID?: string;
|
||||
/**
|
||||
* A string literal describing where in the app resolver is located,
|
||||
* used to prevent collisions in things like query params
|
||||
*/
|
||||
resolverComponentInstanceID: string;
|
||||
}) {
|
||||
/**
|
||||
* This is responsible for dispatching actions that include any external data.
|
||||
* `databaseDocumentID`
|
||||
*/
|
||||
useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID });
|
||||
|
||||
const { timestamp } = useContext(SideEffectContext);
|
||||
|
||||
// use this for the entire render in order to keep things in sync
|
||||
const timeAtRender = timestamp();
|
||||
|
||||
const { processNodePositions, connectingEdgeLineSegments } = useSelector(
|
||||
selectors.visibleNodesAndEdgeLines
|
||||
)(timeAtRender);
|
||||
const terminatedProcesses = useSelector(selectors.terminatedProcesses);
|
||||
const { projectionMatrix, ref, onMouseDown } = useCamera();
|
||||
const isLoading = useSelector(selectors.isLoading);
|
||||
const hasError = useSelector(selectors.hasError);
|
||||
const activeDescendantId = useSelector(selectors.ariaActiveDescendant);
|
||||
const { colorMap } = useResolverTheme();
|
||||
const { cleanUpQueryParams } = useResolverQueryParams();
|
||||
|
||||
useEffectOnce(() => {
|
||||
return () => cleanUpQueryParams();
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledMapContainer className={className} backgroundColor={colorMap.resolverBackground}>
|
||||
{isLoading ? (
|
||||
<div className="loading-container">
|
||||
<EuiLoadingSpinner size="xl" />
|
||||
</div>
|
||||
) : hasError ? (
|
||||
<div className="loading-container">
|
||||
<div>
|
||||
{' '}
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.resolver.loadingError"
|
||||
defaultMessage="Error loading data."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<GraphContainer
|
||||
className="resolver-graph kbn-resetFocusState"
|
||||
onMouseDown={onMouseDown}
|
||||
ref={ref}
|
||||
role="tree"
|
||||
tabIndex={0}
|
||||
aria-activedescendant={activeDescendantId || undefined}
|
||||
>
|
||||
{connectingEdgeLineSegments.map(({ points: [startPosition, endPosition], metadata }) => (
|
||||
<EdgeLine
|
||||
edgeLineMetadata={metadata}
|
||||
key={metadata.uniqueId}
|
||||
startPosition={startPosition}
|
||||
endPosition={endPosition}
|
||||
projectionMatrix={projectionMatrix}
|
||||
/>
|
||||
))}
|
||||
{[...processNodePositions].map(([processEvent, position]) => {
|
||||
const processEntityId = entityIDSafeVersion(processEvent);
|
||||
return (
|
||||
<ProcessEventDot
|
||||
key={processEntityId}
|
||||
position={position}
|
||||
projectionMatrix={projectionMatrix}
|
||||
event={processEvent}
|
||||
isProcessTerminated={terminatedProcesses.has(processEntityId)}
|
||||
timeAtRender={timeAtRender}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</GraphContainer>
|
||||
)}
|
||||
<StyledPanel />
|
||||
<GraphControls />
|
||||
<SymbolDefinitions />
|
||||
</StyledMapContainer>
|
||||
);
|
||||
});
|
|
@ -4,47 +4,143 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history';
|
||||
|
||||
import { noAncestorsTwoChildren } from '../data_access_layer/mocks/no_ancestors_two_children';
|
||||
import { Simulator } from '../test_utilities/simulator';
|
||||
// Extend jest with a custom matcher
|
||||
import '../test_utilities/extend_jest';
|
||||
import { urlSearch } from '../test_utilities/url_search';
|
||||
|
||||
describe('Resolver: when analyzing a tree with no ancestors and two children', () => {
|
||||
let simulator: Simulator;
|
||||
let databaseDocumentID: string;
|
||||
// the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances
|
||||
const resolverComponentInstanceID = 'resolverComponentInstanceID';
|
||||
|
||||
// the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances
|
||||
const resolverComponentInstanceID = 'resolverComponentInstanceID';
|
||||
describe(`Resolver: when analyzing a tree with no ancestors and two children, and when the component instance ID is ${resolverComponentInstanceID}`, () => {
|
||||
/**
|
||||
* Get (or lazily create and get) the simulator.
|
||||
*/
|
||||
let simulator: () => Simulator;
|
||||
/** lazily populated by `simulator`. */
|
||||
let simulatorInstance: Simulator | undefined;
|
||||
let memoryHistory: HistoryPackageHistoryInterface<never>;
|
||||
|
||||
beforeEach(async () => {
|
||||
// node IDs used by the generator
|
||||
let entityIDs: {
|
||||
origin: string;
|
||||
firstChild: string;
|
||||
secondChild: string;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// create a mock data access layer
|
||||
const { metadata: dataAccessLayerMetadata, dataAccessLayer } = noAncestorsTwoChildren();
|
||||
|
||||
// save a reference to the `_id` supported by the mock data layer
|
||||
databaseDocumentID = dataAccessLayerMetadata.databaseDocumentID;
|
||||
entityIDs = dataAccessLayerMetadata.entityIDs;
|
||||
|
||||
memoryHistory = createMemoryHistory();
|
||||
|
||||
// create a resolver simulator, using the data access layer and an arbitrary component instance ID
|
||||
simulator = new Simulator({ databaseDocumentID, dataAccessLayer, resolverComponentInstanceID });
|
||||
simulator = () => {
|
||||
if (simulatorInstance) {
|
||||
return simulatorInstance;
|
||||
} else {
|
||||
simulatorInstance = new Simulator({
|
||||
databaseDocumentID: dataAccessLayerMetadata.databaseDocumentID,
|
||||
dataAccessLayer,
|
||||
resolverComponentInstanceID,
|
||||
history: memoryHistory,
|
||||
});
|
||||
return simulatorInstance;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
it('should show the node list', async () => {
|
||||
await expect(simulator.map(() => simulator.nodeListElement().length)).toYieldEqualTo(1);
|
||||
afterEach(() => {
|
||||
simulatorInstance = undefined;
|
||||
});
|
||||
|
||||
it('should have 3 nodes in the node list', async () => {
|
||||
await expect(simulator.map(() => simulator.nodeListItems().length)).toYieldEqualTo(3);
|
||||
const queryStringWithOriginSelected = urlSearch(resolverComponentInstanceID, {
|
||||
selectedEntityID: 'origin',
|
||||
});
|
||||
describe('when there is an item in the node list and it has been clicked', () => {
|
||||
|
||||
describe(`when the URL query string is ${queryStringWithOriginSelected}`, () => {
|
||||
beforeEach(() => {
|
||||
memoryHistory.push({
|
||||
search: queryStringWithOriginSelected,
|
||||
});
|
||||
});
|
||||
it('should show the node details for the origin', async () => {
|
||||
await expect(
|
||||
simulator().map(() => {
|
||||
const titleWrapper = simulator().nodeDetailViewTitle();
|
||||
const titleIconWrapper = simulator().nodeDetailViewTitleIcon();
|
||||
return {
|
||||
title: titleWrapper.exists() ? titleWrapper.text() : null,
|
||||
titleIcon: titleIconWrapper.exists() ? titleIconWrapper.text() : null,
|
||||
detailEntries: simulator().nodeDetailDescriptionListEntries(),
|
||||
};
|
||||
})
|
||||
).toYieldEqualTo({
|
||||
title: 'c',
|
||||
titleIcon: 'Running Process',
|
||||
detailEntries: [
|
||||
['process.executable', 'executable'],
|
||||
['process.pid', '0'],
|
||||
['user.name', 'user.name'],
|
||||
['user.domain', 'user.domain'],
|
||||
['process.parent.pid', '0'],
|
||||
['process.hash.md5', 'hash.md5'],
|
||||
['process.args', 'args'],
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const queryStringWithFirstChildSelected = urlSearch(resolverComponentInstanceID, {
|
||||
selectedEntityID: 'firstChild',
|
||||
});
|
||||
|
||||
describe(`when the URL query string is ${queryStringWithFirstChildSelected}`, () => {
|
||||
beforeEach(() => {
|
||||
memoryHistory.push({
|
||||
search: queryStringWithFirstChildSelected,
|
||||
});
|
||||
});
|
||||
it('should show the node details for the first child', async () => {
|
||||
await expect(
|
||||
simulator().map(() => simulator().nodeDetailDescriptionListEntries())
|
||||
).toYieldEqualTo([
|
||||
['process.executable', 'executable'],
|
||||
['process.pid', '1'],
|
||||
['user.name', 'user.name'],
|
||||
['user.domain', 'user.domain'],
|
||||
['process.parent.pid', '0'],
|
||||
['process.hash.md5', 'hash.md5'],
|
||||
['process.args', 'args'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have 3 nodes (with icons) in the node list', async () => {
|
||||
await expect(simulator().map(() => simulator().nodeListNodeLinkText().length)).toYieldEqualTo(
|
||||
3
|
||||
);
|
||||
await expect(simulator().map(() => simulator().nodeListNodeLinkIcons().length)).toYieldEqualTo(
|
||||
3
|
||||
);
|
||||
});
|
||||
|
||||
describe('when there is an item in the node list and its text has been clicked', () => {
|
||||
beforeEach(async () => {
|
||||
const nodeListItems = await simulator.resolveWrapper(() => simulator.nodeListItems());
|
||||
expect(nodeListItems && nodeListItems.length).toBeTruthy();
|
||||
if (nodeListItems) {
|
||||
nodeListItems.first().find('button').simulate('click');
|
||||
const nodeLinks = await simulator().resolveWrapper(() => simulator().nodeListNodeLinkText());
|
||||
expect(nodeLinks).toBeTruthy();
|
||||
if (nodeLinks) {
|
||||
nodeLinks.first().simulate('click');
|
||||
}
|
||||
});
|
||||
it('should show the details for the first node', async () => {
|
||||
await expect(
|
||||
simulator.map(() => simulator.nodeDetailDescriptionListEntries())
|
||||
simulator().map(() => simulator().nodeDetailDescriptionListEntries())
|
||||
).toYieldEqualTo([
|
||||
['process.executable', 'executable'],
|
||||
['process.pid', '0'],
|
||||
|
@ -55,5 +151,29 @@ describe('Resolver: when analyzing a tree with no ancestors and two children', (
|
|||
['process.args', 'args'],
|
||||
]);
|
||||
});
|
||||
it("should have the first node's ID in the query string", async () => {
|
||||
await expect(simulator().map(() => simulator().queryStringValues())).toYieldEqualTo({
|
||||
selectedNode: [entityIDs.origin],
|
||||
});
|
||||
});
|
||||
describe('and when the node list link has been clicked', () => {
|
||||
beforeEach(async () => {
|
||||
const nodeListLink = await simulator().resolveWrapper(() =>
|
||||
simulator().nodeDetailBreadcrumbNodeListLink()
|
||||
);
|
||||
if (nodeListLink) {
|
||||
nodeListLink.simulate('click');
|
||||
}
|
||||
});
|
||||
it('should show the list of nodes with links to each node', async () => {
|
||||
await expect(
|
||||
simulator().map(() => {
|
||||
return simulator()
|
||||
.nodeListNodeLinkText()
|
||||
.map((node) => node.text());
|
||||
})
|
||||
).toYieldEqualTo(['c', 'd', 'e']);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,42 +4,55 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
/* eslint-disable react/display-name */
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { useResolverTheme } from '../assets';
|
||||
|
||||
/**
|
||||
* During user testing, one user indicated they wanted to see stronger visual relationships between
|
||||
* Nodes on the graph and what's in the table. Using the same symbol in both places (as below) could help with that.
|
||||
* Icon representing a process node.
|
||||
*/
|
||||
export const CubeForProcess = memo(function CubeForProcess({
|
||||
isProcessTerminated,
|
||||
export const CubeForProcess = memo(function ({
|
||||
running,
|
||||
'data-test-subj': dataTestSubj,
|
||||
}: {
|
||||
isProcessTerminated: boolean;
|
||||
'data-test-subj'?: string;
|
||||
/**
|
||||
* True if the process represented by the node is still running.
|
||||
*/
|
||||
running: boolean;
|
||||
}) {
|
||||
const { cubeAssetsForNode } = useResolverTheme();
|
||||
const { cubeSymbol, descriptionText } = cubeAssetsForNode(isProcessTerminated, false);
|
||||
const { cubeSymbol } = cubeAssetsForNode(!running, false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<svg
|
||||
style={{ position: 'relative', top: '0.4em', marginRight: '.25em' }}
|
||||
className="table-process-icon"
|
||||
width="1.5em"
|
||||
height="1.5em"
|
||||
viewBox="0 0 1 1"
|
||||
>
|
||||
<desc>{descriptionText}</desc>
|
||||
<use
|
||||
role="presentation"
|
||||
xlinkHref={cubeSymbol}
|
||||
x={0}
|
||||
y={0}
|
||||
width={1}
|
||||
height={1}
|
||||
opacity="1"
|
||||
className="cube"
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
<StyledSVG width="1.5em" height="1.5em" viewBox="0 0 1 1" data-test-subj={dataTestSubj}>
|
||||
<desc>
|
||||
{i18n.translate('xpack.securitySolution.resolver.node_icon', {
|
||||
defaultMessage: '{running, select, true {Running Process} false {Terminated Process}}',
|
||||
values: { running },
|
||||
})}
|
||||
</desc>
|
||||
<use
|
||||
role="presentation"
|
||||
xlinkHref={cubeSymbol}
|
||||
x={0}
|
||||
y={0}
|
||||
width={1}
|
||||
height={1}
|
||||
opacity="1"
|
||||
className="cube"
|
||||
/>
|
||||
</StyledSVG>
|
||||
);
|
||||
});
|
||||
|
||||
const StyledSVG = styled.svg`
|
||||
position: relative;
|
||||
top: 0.4em;
|
||||
margin-right: 0.25em;
|
||||
`;
|
||||
|
|
|
@ -220,7 +220,7 @@ PanelContent.displayName = 'PanelContent';
|
|||
|
||||
export const Panel = memo(function Event({ className }: { className?: string }) {
|
||||
return (
|
||||
<EuiPanel className={className} data-test-subj="resolver:panel">
|
||||
<EuiPanel className={className}>
|
||||
<PanelContent />
|
||||
</EuiPanel>
|
||||
);
|
||||
|
|
|
@ -129,6 +129,7 @@ export const ProcessDetails = memo(function ProcessDetails({
|
|||
defaultMessage: 'Events',
|
||||
}
|
||||
),
|
||||
'data-test-subj': 'resolver:node-detail:breadcrumbs:node-list-link',
|
||||
onClick: () => {
|
||||
pushToQueryParams({ crumbId: '', crumbEvent: '' });
|
||||
},
|
||||
|
@ -155,20 +156,23 @@ export const ProcessDetails = memo(function ProcessDetails({
|
|||
return cubeAssetsForNode(isProcessTerminated, false);
|
||||
}, [processEvent, cubeAssetsForNode, isProcessTerminated]);
|
||||
|
||||
const titleId = useMemo(() => htmlIdGenerator('resolverTable')(), []);
|
||||
const titleID = useMemo(() => htmlIdGenerator('resolverTable')(), []);
|
||||
return (
|
||||
<>
|
||||
<StyledBreadcrumbs breadcrumbs={crumbs} />
|
||||
<EuiSpacer size="l" />
|
||||
<EuiTitle size="xs">
|
||||
<h4 aria-describedby={titleId}>
|
||||
<CubeForProcess isProcessTerminated={isProcessTerminated} />
|
||||
{processName}
|
||||
<h4 aria-describedby={titleID}>
|
||||
<CubeForProcess
|
||||
data-test-subj="resolver:node-detail:title-icon"
|
||||
running={!isProcessTerminated}
|
||||
/>
|
||||
<span data-test-subj="resolver:node-detail:title">{processName}</span>
|
||||
</h4>
|
||||
</EuiTitle>
|
||||
<EuiText>
|
||||
<EuiTextColor color="subdued">
|
||||
<span id={titleId}>{descriptionText}</span>
|
||||
<span id={titleID}>{descriptionText}</span>
|
||||
</EuiTextColor>
|
||||
</EuiText>
|
||||
<EuiSpacer size="l" />
|
||||
|
|
|
@ -111,8 +111,11 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({
|
|||
});
|
||||
}}
|
||||
>
|
||||
<CubeForProcess isProcessTerminated={isTerminated} />
|
||||
{name}
|
||||
<CubeForProcess
|
||||
running={!isTerminated}
|
||||
data-test-subj="resolver:node-list:node-link:icon"
|
||||
/>
|
||||
<span data-test-subj="resolver:node-list:node-link:title">{name}</span>
|
||||
</EuiButtonEmpty>
|
||||
);
|
||||
},
|
||||
|
@ -150,18 +153,10 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({
|
|||
const processTableView: ProcessTableView[] = useMemo(
|
||||
() =>
|
||||
[...processNodePositions.keys()].map((processEvent) => {
|
||||
let dateTime: Date | undefined;
|
||||
const eventTime = event.timestampSafeVersion(processEvent);
|
||||
const name = event.processNameSafeVersion(processEvent);
|
||||
if (eventTime) {
|
||||
const date = new Date(eventTime);
|
||||
if (isFinite(date.getTime())) {
|
||||
dateTime = date;
|
||||
}
|
||||
}
|
||||
return {
|
||||
name,
|
||||
timestamp: dateTime,
|
||||
timestamp: event.timestampAsDateSafeVersion(processEvent),
|
||||
event: processEvent,
|
||||
};
|
||||
}),
|
||||
|
@ -172,12 +167,9 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({
|
|||
const crumbs = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
text: i18n.translate(
|
||||
'xpack.securitySolution.endpoint.resolver.panel.processListWithCounts.events',
|
||||
{
|
||||
defaultMessage: 'All Process Events',
|
||||
}
|
||||
),
|
||||
text: i18n.translate('xpack.securitySolution.resolver.panel.nodeList.title', {
|
||||
defaultMessage: 'All Process Events',
|
||||
}),
|
||||
onClick: () => {},
|
||||
},
|
||||
];
|
||||
|
|
|
@ -4,8 +4,12 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-duplicate-imports */
|
||||
|
||||
/* eslint-disable react/display-name */
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React, { ReactNode, useState, useMemo, useCallback, useRef, useLayoutEffect } from 'react';
|
||||
import React, { useState, useMemo, useCallback, useRef, useLayoutEffect } from 'react';
|
||||
import {
|
||||
EuiI18nNumber,
|
||||
EuiSelectable,
|
||||
|
@ -15,6 +19,7 @@ import {
|
|||
htmlIdGenerator,
|
||||
} from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
import { EuiSelectableOption } from '@elastic/eui';
|
||||
import { Matrix3 } from '../types';
|
||||
|
||||
/**
|
||||
|
@ -59,21 +64,21 @@ const OptionList = React.memo(
|
|||
subMenuOptions: ResolverSubmenuOptionList;
|
||||
isLoading: boolean;
|
||||
}) => {
|
||||
const [options, setOptions] = useState(() =>
|
||||
const [options, setOptions] = useState<EuiSelectableOption[]>(() =>
|
||||
typeof subMenuOptions !== 'object'
|
||||
? []
|
||||
: subMenuOptions.map((opt: ResolverSubmenuOption): {
|
||||
label: string;
|
||||
prepend?: ReactNode;
|
||||
} => {
|
||||
return opt.prefix
|
||||
: subMenuOptions.map((option: ResolverSubmenuOption) => {
|
||||
const dataTestSubj = 'resolver:map:node-submenu-item';
|
||||
return option.prefix
|
||||
? {
|
||||
label: opt.optionTitle,
|
||||
prepend: <span>{opt.prefix} </span>,
|
||||
label: option.optionTitle,
|
||||
prepend: <span>{option.prefix} </span>,
|
||||
'data-test-subj': dataTestSubj,
|
||||
}
|
||||
: {
|
||||
label: opt.optionTitle,
|
||||
label: option.optionTitle,
|
||||
prepend: <span />,
|
||||
'data-test-subj': dataTestSubj,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
@ -88,11 +93,10 @@ const OptionList = React.memo(
|
|||
}, {});
|
||||
}, [subMenuOptions]);
|
||||
|
||||
type ChangeOptions = Array<{ label: string; prepend?: ReactNode; checked?: string }>;
|
||||
const selectableProps = useMemo(() => {
|
||||
return {
|
||||
listProps: { showIcons: true, bordered: true },
|
||||
onChange: (newOptions: ChangeOptions) => {
|
||||
onChange: (newOptions: EuiSelectableOption[]) => {
|
||||
const selectedOption = newOptions.find((opt) => opt.checked === 'on');
|
||||
if (selectedOption) {
|
||||
const { label } = selectedOption;
|
||||
|
@ -119,8 +123,6 @@ const OptionList = React.memo(
|
|||
}
|
||||
);
|
||||
|
||||
OptionList.displayName = 'OptionList';
|
||||
|
||||
/**
|
||||
* A Submenu to be displayed in one of two forms:
|
||||
* 1) Provided a collection of `optionsWithActions`: it will call `menuAction` then - if and when menuData becomes available - display each item with an optional prefix and call the supplied action for the options when that option is clicked.
|
||||
|
@ -259,8 +261,6 @@ const NodeSubMenuComponents = React.memo(
|
|||
}
|
||||
);
|
||||
|
||||
NodeSubMenuComponents.displayName = 'NodeSubMenu';
|
||||
|
||||
export const NodeSubMenu = styled(NodeSubMenuComponents)`
|
||||
margin: 2px 0 0 0;
|
||||
padding: 0;
|
||||
|
|
|
@ -16380,7 +16380,7 @@
|
|||
"xpack.securitySolution.endpoint.resolver.panel.processEventListByType.eventDescriptiveName": "{descriptor} {subject}",
|
||||
"xpack.securitySolution.endpoint.resolver.panel.processEventListByType.events": "イベント",
|
||||
"xpack.securitySolution.endpoint.resolver.panel.processEventListByType.wait": "イベントを待機しています...",
|
||||
"xpack.securitySolution.endpoint.resolver.panel.processListWithCounts.events": "すべてのプロセスイベント",
|
||||
"xpack.securitySolution.resolver.panel.nodeList.title": "すべてのプロセスイベント",
|
||||
"xpack.securitySolution.endpoint.resolver.panel.relatedCounts.numberOfEventsInCrumb": "{totalCount}件のイベント",
|
||||
"xpack.securitySolution.endpoint.resolver.panel.relatedDetail.missing": "関連イベントが見つかりません。",
|
||||
"xpack.securitySolution.endpoint.resolver.panel.relatedDetail.wait": "イベントを待機しています...",
|
||||
|
|
|
@ -16386,7 +16386,7 @@
|
|||
"xpack.securitySolution.endpoint.resolver.panel.processEventListByType.eventDescriptiveName": "{descriptor} {subject}",
|
||||
"xpack.securitySolution.endpoint.resolver.panel.processEventListByType.events": "事件",
|
||||
"xpack.securitySolution.endpoint.resolver.panel.processEventListByType.wait": "等候事件......",
|
||||
"xpack.securitySolution.endpoint.resolver.panel.processListWithCounts.events": "所有进程事件",
|
||||
"xpack.securitySolution.resolver.panel.nodeList.title": "所有进程事件",
|
||||
"xpack.securitySolution.endpoint.resolver.panel.relatedCounts.numberOfEventsInCrumb": "{totalCount} 个事件",
|
||||
"xpack.securitySolution.endpoint.resolver.panel.relatedDetail.missing": "找不到相关事件。",
|
||||
"xpack.securitySolution.endpoint.resolver.panel.relatedDetail.wait": "等候事件......",
|
||||
|
|
Loading…
Reference in a new issue