[IngestManager] Remove ingest manager and endpoint from 7.7 (#61911)

This commit is contained in:
Nicolas Chaulet 2020-04-02 08:29:42 -04:00 committed by GitHub
parent 2bc83b1cf4
commit 09badb9e60
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
566 changed files with 1 additions and 59795 deletions

View file

@ -1,144 +0,0 @@
[role="xpack"]
[[epm]]
== Elastic Package Manager
These are the docs for the Elastic Package Manager (EPM).
=== Configuration
The Elastic Package Manager by default access `epr.elastic.co` to retrieve the package. The url can be configured with:
```
xpack.epm.registryUrl: 'http://localhost:8080'
```
=== API
The Package Manager offers an API. Here an example on how they can be used.
List installed packages:
```
curl localhost:5601/api/ingest_manager/epm/packages
```
Install a package:
```
curl -X POST localhost:5601/api/ingest_manager/epm/packages/iptables-1.0.4
```
Delete a package:
```
curl -X DELETE localhost:5601/api/ingest_manager/epm/packages/iptables-1.0.4
```
=== Definitions
This section is to define terms used across ingest management.
==== Elastic Agent
A single, unified agent that users can deploy to hosts or containers. It controls which data is collected from the host or containers and where the data is sent. It will run Beats, Endpoint or other monitoring programs as needed. It can operate standalone or pull a configuration policy from Fleet.
==== Namespace
A user-specified string that will be used to part of the index name in Elasticsearch. It helps users identify logs coming from a specific environment (like prod or test), an application, or other identifiers.
==== Package
A package contains all the assets for the Elastic Stack. A more detailed definition of a package can be found under https://github.com/elastic/package-registry.
== Indexing Strategy
Ingest Management enforces an indexing strategy to allow the system to automatically detect indices and run queries on it. In short the indexing strategy looks as following:
```
{type}-{dataset}-{namespace}
```
The `{type}` can be `logs` or `metrics`. The `{namespace}` is the part where the user can use free form. The only two requirement are that it has only characters allowed in an Elasticsearch index name and does NOT contain a `-`. The `dataset` is defined by the data that is indexed. The same requirements as for the namespace apply. It is expected that the fields for type, namespace and dataset are part of each event and are constant keywords.
Note: More `{type}`s might be added in the future like `apm` and `endpoint`.
This indexing strategy has a few advantages:
* Each index contains only the fields which are relevant for the dataset. This leads to more dense indices and better field completion.
* ILM policies can be applied per namespace per dataset.
* Rollups can be specified per namespace per dataset.
* Having the namespace user configurable makes setting security permissions possible.
* Having a global metrics and logs template, allows to create new indices on demand which still follow the convention. This is common in the case of k8s as an example.
* Constant keywords allow to narrow down the indices we need to access for querying very efficiently. This is especially relevant in environments which a large number of indices or with indices on slower nodes.
=== Ingest Pipeline
The ingest pipelines for a specific dataset will have the following naming scheme:
```
{type}-{dataset}-{package.version}
```
As an example, the ingest pipeline for the Nginx access logs is called `logs-nginx.access-3.4.1`. The same ingest pipeline is used for all namespaces. It is possible that a dataset has multiple ingest pipelines in which case a suffix is added to the name.
The version is included in each pipeline to allow upgrades. The pipeline itself is listed in the index template and is automatically applied at ingest time.
=== Templates & ILM Policies
To make the above strategy possible, alias templates are required. For each type there is a basic alias template with a default ILM policy. These default templates apply to all indices which follow the indexing strategy and do not have a more specific dataset alias template.
The `metrics` and `logs` alias template contain all the basic fields from ECS.
Each type template contains an ILM policy. Modifying this default ILM policy will affect all data covered by the default templates.
The templates for a dataset are called as following:
```
{type}-{dataset}
```
The pattern used inside the index template is `{type}-{dataset}-*` to match all namespaces.
=== Defaults
If the Elastic Agent is used to ingest data and only the type is specified, `default` for the namespace is used and `generic` for the dataset.
=== Data filtering
Filtering for data in queries for example in visualizations or dashboards should always be done on the constant keyword fields. Visualizations needing data for the nginx.access dataset should query on `type:logs AND dataset:nginx.access`. As these are constant keywords the prefiltering is very efficient.
=== Security permissions
Security permissions can be set on different levels. To set special permissions for the access on the prod namespace, use the following index pattern:
```
/(logs|metrics)-[^-]+-prod-$/
```
To set specific permissions on the logs index, the following can be used:
```
/^(logs|metrics)-.*/
```
Todo: The above queries need to be tested.
== Package Manager
=== Package Upgrades
When upgrading a package between a bugfix or a minor version, no breaking changes should happen. Upgrading a package has the following effect:
* Removal of existing dashboards
* Installation of new dashboards
* Write new ingest pipelines with the version
* Write new Elasticsearch alias templates
* Trigger a rollover for all the affected indices
The new ingest pipeline is expected to still work with the data coming from older configurations. In most cases this means some of the fields can be missing. For this to work, each event must contain the version of config / package it is coming from to make such a decision.
In case of a breaking change in the data structure, the new ingest pipeline is also expected to deal with this change. In case there are breaking changes which cannot be dealt with in an ingest pipeline, a new package has to be created.
Each package lists its minimal required agent version. In case there are agents enrolled with an older version, the user is notified to upgrade these agents as otherwise the new configs cannot be rolled out.

View file

@ -12,7 +12,6 @@
"xpack.dashboardMode": "legacy/plugins/dashboard_mode",
"xpack.data": "plugins/data_enhanced",
"xpack.drilldowns": "plugins/drilldowns",
"xpack.endpoint": "plugins/endpoint",
"xpack.features": "plugins/features",
"xpack.fileUpload": "plugins/file_upload",
"xpack.graph": ["legacy/plugins/graph", "plugins/graph"],
@ -20,7 +19,6 @@
"xpack.idxMgmt": "plugins/index_management",
"xpack.indexLifecycleMgmt": "legacy/plugins/index_lifecycle_management",
"xpack.infra": "plugins/infra",
"xpack.ingestManager": "plugins/ingest_manager",
"xpack.lens": "legacy/plugins/lens",
"xpack.licenseMgmt": "plugins/license_management",
"xpack.licensing": "plugins/licensing",

View file

@ -32,7 +32,6 @@ import { encryptedSavedObjects } from './legacy/plugins/encrypted_saved_objects'
import { actions } from './legacy/plugins/actions';
import { alerting } from './legacy/plugins/alerting';
import { lens } from './legacy/plugins/lens';
import { ingestManager } from './legacy/plugins/ingest_manager';
import { triggersActionsUI } from './legacy/plugins/triggers_actions_ui';
module.exports = function(kibana) {
@ -65,7 +64,6 @@ module.exports = function(kibana) {
lens(kibana),
actions(kibana),
alerting(kibana),
ingestManager(kibana),
triggersActionsUI(kibana),
];
};

View file

@ -1,46 +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 { resolve } from 'path';
import {
savedObjectMappings,
OUTPUT_SAVED_OBJECT_TYPE,
AGENT_CONFIG_SAVED_OBJECT_TYPE,
DATASOURCE_SAVED_OBJECT_TYPE,
PACKAGES_SAVED_OBJECT_TYPE,
} from '../../../plugins/ingest_manager/server';
// TODO https://github.com/elastic/kibana/issues/46373
// const INDEX_NAMES = {
// INGEST: '.kibana',
// };
export function ingestManager(kibana: any) {
return new kibana.Plugin({
id: 'ingestManager',
publicDir: resolve(__dirname, '../../../plugins/ingest_manager/public'),
uiExports: {
savedObjectSchemas: {
[AGENT_CONFIG_SAVED_OBJECT_TYPE]: {
isNamespaceAgnostic: true,
// indexPattern: INDEX_NAMES.INGEST,
},
[OUTPUT_SAVED_OBJECT_TYPE]: {
isNamespaceAgnostic: true,
// indexPattern: INDEX_NAMES.INGEST,
},
[DATASOURCE_SAVED_OBJECT_TYPE]: {
isNamespaceAgnostic: true,
// indexPattern: INDEX_NAMES.INGEST,
},
[PACKAGES_SAVED_OBJECT_TYPE]: {
isNamespaceAgnostic: true,
// indexPattern: INDEX_NAMES.INGEST,
},
},
mappings: savedObjectMappings,
},
});
}

View file

@ -1,170 +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 { EndpointDocGenerator, Event } from './generate_data';
interface Node {
events: Event[];
children: Node[];
parent_entity_id?: string;
}
describe('data generator', () => {
let generator: EndpointDocGenerator;
beforeEach(() => {
generator = new EndpointDocGenerator('seed');
});
it('creates the same documents with same random seed', () => {
const generator1 = new EndpointDocGenerator('seed');
const generator2 = new EndpointDocGenerator('seed');
const timestamp = new Date().getTime();
const metadata1 = generator1.generateHostMetadata(timestamp);
const metadata2 = generator2.generateHostMetadata(timestamp);
expect(metadata1).toEqual(metadata2);
});
it('creates different documents with different random seeds', () => {
const generator1 = new EndpointDocGenerator('seed');
const generator2 = new EndpointDocGenerator('different seed');
const timestamp = new Date().getTime();
const metadata1 = generator1.generateHostMetadata(timestamp);
const metadata2 = generator2.generateHostMetadata(timestamp);
expect(metadata1).not.toEqual(metadata2);
});
it('creates host metadata documents', () => {
const timestamp = new Date().getTime();
const metadata = generator.generateHostMetadata(timestamp);
expect(metadata['@timestamp']).toEqual(timestamp);
expect(metadata.event.created).toEqual(timestamp);
expect(metadata.endpoint).not.toBeNull();
expect(metadata.agent).not.toBeNull();
expect(metadata.host).not.toBeNull();
});
it('creates alert event documents', () => {
const timestamp = new Date().getTime();
const alert = generator.generateAlert(timestamp);
expect(alert['@timestamp']).toEqual(timestamp);
expect(alert.event.action).not.toBeNull();
expect(alert.endpoint).not.toBeNull();
expect(alert.agent).not.toBeNull();
expect(alert.host).not.toBeNull();
expect(alert.process.entity_id).not.toBeNull();
});
it('creates process event documents', () => {
const timestamp = new Date().getTime();
const processEvent = generator.generateEvent({ timestamp });
expect(processEvent['@timestamp']).toEqual(timestamp);
expect(processEvent.event.category).toEqual('process');
expect(processEvent.event.kind).toEqual('event');
expect(processEvent.event.type).toEqual('start');
expect(processEvent.agent).not.toBeNull();
expect(processEvent.host).not.toBeNull();
expect(processEvent.process.entity_id).not.toBeNull();
expect(processEvent.process.name).not.toBeNull();
});
it('creates other event documents', () => {
const timestamp = new Date().getTime();
const processEvent = generator.generateEvent({ timestamp, eventCategory: 'dns' });
expect(processEvent['@timestamp']).toEqual(timestamp);
expect(processEvent.event.category).toEqual('dns');
expect(processEvent.event.kind).toEqual('event');
expect(processEvent.event.type).toEqual('start');
expect(processEvent.agent).not.toBeNull();
expect(processEvent.host).not.toBeNull();
expect(processEvent.process.entity_id).not.toBeNull();
expect(processEvent.process.name).not.toBeNull();
});
describe('creates alert ancestor tree', () => {
let events: Event[];
beforeEach(() => {
events = generator.generateAlertEventAncestry(3);
});
it('with n-1 process events', () => {
for (let i = 1; i < events.length - 1; i++) {
expect(events[i].process.parent?.entity_id).toEqual(events[i - 1].process.entity_id);
expect(events[i].event.kind).toEqual('event');
expect(events[i].event.category).toEqual('process');
}
});
it('with a corresponding alert at the end', () => {
// The alert should be last and have the same entity_id as the previous process event
expect(events[events.length - 1].process.entity_id).toEqual(
events[events.length - 2].process.entity_id
);
expect(events[events.length - 1].process.parent?.entity_id).toEqual(
events[events.length - 2].process.parent?.entity_id
);
expect(events[events.length - 1].event.kind).toEqual('alert');
expect(events[events.length - 1].event.category).toEqual('malware');
});
});
function buildResolverTree(events: Event[]): Node {
// First pass we gather up all the events by entity_id
const tree: Record<string, Node> = {};
events.forEach(event => {
if (event.process.entity_id in tree) {
tree[event.process.entity_id].events.push(event);
} else {
tree[event.process.entity_id] = {
events: [event],
children: [],
parent_entity_id: event.process.parent?.entity_id,
};
}
});
// Second pass add child references to each node
for (const value of Object.values(tree)) {
if (value.parent_entity_id) {
tree[value.parent_entity_id].children.push(value);
}
}
// The root node must be first in the array or this fails
return tree[events[0].process.entity_id];
}
function countResolverEvents(rootNode: Node, generations: number): number {
// Start at the root, traverse N levels of the tree and check that we found all nodes
let nodes = [rootNode];
let visitedEvents = 0;
for (let i = 0; i < generations + 1; i++) {
let nextNodes: Node[] = [];
nodes.forEach(node => {
nextNodes = nextNodes.concat(node.children);
visitedEvents += node.events.length;
});
nodes = nextNodes;
}
return visitedEvents;
}
it('creates tree of process children', () => {
const timestamp = new Date().getTime();
const root = generator.generateEvent({ timestamp });
const generations = 2;
const events = [root, ...generator.generateDescendantsTree(root, generations)];
const rootNode = buildResolverTree(events);
const visitedEvents = countResolverEvents(rootNode, generations);
expect(visitedEvents).toEqual(events.length);
});
it('creates full resolver tree', () => {
const alertAncestors = 3;
const generations = 2;
const events = generator.generateFullResolverTree(alertAncestors, generations);
const rootNode = buildResolverTree(events);
const visitedEvents = countResolverEvents(rootNode, alertAncestors + generations);
expect(visitedEvents).toEqual(events.length);
});
});

View file

@ -1,428 +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 uuid from 'uuid';
import seedrandom from 'seedrandom';
import { AlertEvent, EndpointEvent, HostMetadata, OSFields, HostFields } from './types';
export type Event = AlertEvent | EndpointEvent;
interface EventOptions {
timestamp?: number;
entityID?: string;
parentEntityID?: string;
eventType?: string;
eventCategory?: string;
processName?: string;
}
const Windows: OSFields[] = [
{
name: 'windows 10.0',
full: 'Windows 10',
version: '10.0',
variant: 'Windows Pro',
},
{
name: 'windows 10.0',
full: 'Windows Server 2016',
version: '10.0',
variant: 'Windows Server',
},
{
name: 'windows 6.2',
full: 'Windows Server 2012',
version: '6.2',
variant: 'Windows Server',
},
{
name: 'windows 6.3',
full: 'Windows Server 2012R2',
version: '6.3',
variant: 'Windows Server Release 2',
},
];
const Linux: OSFields[] = [];
const Mac: OSFields[] = [];
const OS: OSFields[] = [...Windows, ...Mac, ...Linux];
const POLICIES: Array<{ name: string; id: string }> = [
{
name: 'Default',
id: '00000000-0000-0000-0000-000000000000',
},
{
name: 'With Eventing',
id: 'C2A9093E-E289-4C0A-AA44-8C32A414FA7A',
},
];
const FILE_OPERATIONS: string[] = ['creation', 'open', 'rename', 'execution', 'deletion'];
interface EventInfo {
category: string;
/**
* This denotes the `event.type` field for when an event is created, this can be `start` or `creation`
*/
creationType: string;
}
// These are from the v1 schemas and aren't all valid ECS event categories, still in flux
const OTHER_EVENT_CATEGORIES: EventInfo[] = [
{ category: 'driver', creationType: 'start' },
{ category: 'file', creationType: 'creation' },
{ category: 'library', creationType: 'start' },
{ category: 'network', creationType: 'start' },
{ category: 'registry', creationType: 'creation' },
];
interface HostInfo {
agent: {
version: string;
id: string;
};
host: HostFields;
endpoint: {
policy: {
id: string;
};
};
}
export class EndpointDocGenerator {
commonInfo: HostInfo;
random: seedrandom.prng;
constructor(seed = Math.random().toString()) {
this.random = seedrandom(seed);
this.commonInfo = this.createHostData();
}
// This function will create new values for all the host fields, so documents from a different host can be created
// This provides a convenient way to make documents from multiple hosts that are all tied to a single seed value
public randomizeHostData() {
this.commonInfo = this.createHostData();
}
private createHostData(): HostInfo {
return {
agent: {
version: this.randomVersion(),
id: this.seededUUIDv4(),
},
host: {
id: this.seededUUIDv4(),
hostname: this.randomHostname(),
ip: this.randomArray(3, () => this.randomIP()),
mac: this.randomArray(3, () => this.randomMac()),
os: this.randomChoice(OS),
},
endpoint: {
policy: this.randomChoice(POLICIES),
},
};
}
public generateHostMetadata(ts = new Date().getTime()): HostMetadata {
return {
'@timestamp': ts,
event: {
created: ts,
},
...this.commonInfo,
};
}
public generateAlert(
ts = new Date().getTime(),
entityID = this.randomString(10),
parentEntityID?: string
): AlertEvent {
return {
...this.commonInfo,
'@timestamp': ts,
event: {
action: this.randomChoice(FILE_OPERATIONS),
kind: 'alert',
category: 'malware',
id: this.seededUUIDv4(),
dataset: 'endpoint',
module: 'endpoint',
type: 'creation',
},
file: {
owner: 'SYSTEM',
name: 'fake_malware.exe',
path: 'C:/fake_malware.exe',
accessed: ts,
mtime: ts,
created: ts,
size: 3456,
hash: {
md5: 'fake file md5',
sha1: 'fake file sha1',
sha256: 'fake file sha256',
},
code_signature: {
trusted: false,
subject_name: 'bad signer',
},
malware_classifier: {
identifier: 'endpointpe',
score: 1,
threshold: 0.66,
version: '3.0.33',
},
temp_file_path: 'C:/temp/fake_malware.exe',
},
process: {
pid: 2,
name: 'malware writer',
start: ts,
uptime: 0,
user: 'SYSTEM',
entity_id: entityID,
executable: 'C:/malware.exe',
parent: parentEntityID ? { entity_id: parentEntityID, pid: 1 } : undefined,
token: {
domain: 'NT AUTHORITY',
integrity_level: 16384,
integrity_level_name: 'system',
privileges: [
{
description: 'Replace a process level token',
enabled: false,
name: 'SeAssignPrimaryTokenPrivilege',
},
],
sid: 'S-1-5-18',
type: 'tokenPrimary',
user: 'SYSTEM',
},
code_signature: {
trusted: false,
subject_name: 'bad signer',
},
hash: {
md5: 'fake md5',
sha1: 'fake sha1',
sha256: 'fake sha256',
},
},
dll: [
{
pe: {
architecture: 'x64',
imphash: 'c30d230b81c734e82e86e2e2fe01cd01',
},
code_signature: {
subject_name: 'Cybereason Inc',
trusted: true,
},
compile_time: 1534424710,
hash: {
md5: '1f2d082566b0fc5f2c238a5180db7451',
sha1: 'ca85243c0af6a6471bdaa560685c51eefd6dbc0d',
sha256: '8ad40c90a611d36eb8f9eb24fa04f7dbca713db383ff55a03aa0f382e92061a2',
},
malware_classifier: {
identifier: 'Whitelisted',
score: 0,
threshold: 0,
version: '3.0.0',
},
mapped_address: 5362483200,
mapped_size: 0,
path: 'C:\\Program Files\\Cybereason ActiveProbe\\AmSvc.exe',
},
],
};
}
public generateEvent(options: EventOptions = {}): EndpointEvent {
return {
'@timestamp': options.timestamp ? options.timestamp : new Date().getTime(),
agent: { ...this.commonInfo.agent, type: 'endpoint' },
ecs: {
version: '1.4.0',
},
event: {
category: options.eventCategory ? options.eventCategory : 'process',
kind: 'event',
type: options.eventType ? options.eventType : 'start',
id: this.seededUUIDv4(),
},
host: this.commonInfo.host,
process: {
entity_id: options.entityID ? options.entityID : this.randomString(10),
parent: options.parentEntityID ? { entity_id: options.parentEntityID } : undefined,
name: options.processName ? options.processName : 'powershell.exe',
},
};
}
public generateFullResolverTree(
alertAncestors?: number,
childGenerations?: number,
maxChildrenPerNode?: number,
relatedEventsPerNode?: number,
percentNodesWithRelated?: number,
percentChildrenTerminated?: number
): Event[] {
const ancestry = this.generateAlertEventAncestry(alertAncestors);
// ancestry will always have at least 2 elements, and the second to last element will be the process associated with the alert
const descendants = this.generateDescendantsTree(
ancestry[ancestry.length - 2],
childGenerations,
maxChildrenPerNode,
relatedEventsPerNode,
percentNodesWithRelated,
percentChildrenTerminated
);
return ancestry.concat(descendants);
}
public generateAlertEventAncestry(alertAncestors = 3): Event[] {
const events = [];
const startDate = new Date().getTime();
const root = this.generateEvent({ timestamp: startDate + 1000 });
events.push(root);
let ancestor = root;
for (let i = 0; i < alertAncestors; i++) {
ancestor = this.generateEvent({
timestamp: startDate + 1000 * (i + 1),
parentEntityID: ancestor.process.entity_id,
});
events.push(ancestor);
}
events.push(
this.generateAlert(
startDate + 1000 * alertAncestors,
ancestor.process.entity_id,
ancestor.process.parent?.entity_id
)
);
return events;
}
public generateDescendantsTree(
root: Event,
generations = 2,
maxChildrenPerNode = 2,
relatedEventsPerNode = 3,
percentNodesWithRelated = 100,
percentChildrenTerminated = 100
): Event[] {
let events: Event[] = [];
let parents = [root];
let timestamp = root['@timestamp'];
for (let i = 0; i < generations; i++) {
const newParents: EndpointEvent[] = [];
parents.forEach(element => {
const numChildren = this.randomN(maxChildrenPerNode + 1);
for (let j = 0; j < numChildren; j++) {
timestamp = timestamp + 1000;
const child = this.generateEvent({
timestamp,
parentEntityID: element.process.entity_id,
});
newParents.push(child);
}
});
events = events.concat(newParents);
parents = newParents;
}
const terminationEvents: EndpointEvent[] = [];
let relatedEvents: EndpointEvent[] = [];
events.forEach(element => {
if (this.randomN(100) < percentChildrenTerminated) {
timestamp = timestamp + 1000;
terminationEvents.push(
this.generateEvent({
timestamp,
entityID: element.process.entity_id,
parentEntityID: element.process.parent?.entity_id,
eventCategory: 'process',
eventType: 'end',
})
);
}
if (this.randomN(100) < percentNodesWithRelated) {
relatedEvents = relatedEvents.concat(
this.generateRelatedEvents(element, relatedEventsPerNode)
);
}
});
events = events.concat(terminationEvents);
events = events.concat(relatedEvents);
return events;
}
public generateRelatedEvents(node: Event, numRelatedEvents = 10): EndpointEvent[] {
const ts = node['@timestamp'] + 1000;
const relatedEvents: EndpointEvent[] = [];
for (let i = 0; i < numRelatedEvents; i++) {
const eventInfo = this.randomChoice(OTHER_EVENT_CATEGORIES);
relatedEvents.push(
this.generateEvent({
timestamp: ts,
entityID: node.process.entity_id,
parentEntityID: node.process.parent?.entity_id,
eventCategory: eventInfo.category,
eventType: eventInfo.creationType,
})
);
}
return relatedEvents;
}
private randomN(n: number): number {
return Math.floor(this.random() * n);
}
private *randomNGenerator(max: number, count: number) {
while (count > 0) {
yield this.randomN(max);
count--;
}
}
private randomArray<T>(lengthLimit: number, generator: () => T): T[] {
const rand = this.randomN(lengthLimit) + 1;
return [...Array(rand).keys()].map(generator);
}
private randomMac(): string {
return [...this.randomNGenerator(255, 6)].map(x => x.toString(16)).join('-');
}
private randomIP(): string {
return [10, ...this.randomNGenerator(255, 3)].map(x => x.toString()).join('.');
}
private randomVersion(): string {
return [6, ...this.randomNGenerator(10, 2)].map(x => x.toString()).join('.');
}
private randomChoice<T>(choices: T[]): T {
return choices[this.randomN(choices.length)];
}
private randomString(length: number): string {
return [...this.randomNGenerator(36, length)].map(x => x.toString(36)).join('');
}
private randomHostname(): string {
return `Host-${this.randomString(10)}`;
}
private seededUUIDv4(): string {
return uuid.v4({ random: [...this.randomNGenerator(255, 16)] });
}
}

View file

@ -1,31 +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 { EndpointEvent, LegacyEndpointEvent } from '../types';
export function isLegacyEvent(
event: EndpointEvent | LegacyEndpointEvent
): event is LegacyEndpointEvent {
return (event as LegacyEndpointEvent).endgame !== undefined;
}
export function eventTimestamp(
event: EndpointEvent | LegacyEndpointEvent
): string | undefined | number {
if (isLegacyEvent(event)) {
return event.endgame.timestamp_utc;
} else {
return event['@timestamp'];
}
}
export function eventName(event: EndpointEvent | LegacyEndpointEvent): string {
if (isLegacyEvent(event)) {
return event.endgame.process_name ? event.endgame.process_name : '';
} else {
return event.process.name;
}
}

View file

@ -1,6 +0,0 @@
# Schemas
These schemas are used to validate, coerce, and provide types for the comms between the client, server, and ES.
# Future work
In the future, we may be able to locate these under 'server'.

View file

@ -1,120 +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 { schema, Type } from '@kbn/config-schema';
import { i18n } from '@kbn/i18n';
import { decode } from 'rison-node';
import { EndpointAppConstants } from '../types';
/**
* Used to validate GET requests against the index of the alerting APIs.
*/
export const alertingIndexGetQuerySchema = schema.object(
{
page_size: schema.maybe(
schema.number({
min: 1,
max: 100,
})
),
page_index: schema.maybe(
schema.number({
min: 0,
})
),
after: schema.maybe(
schema.arrayOf(schema.string(), {
minSize: 2,
maxSize: 2,
}) as Type<[string, string]> // Cast this to a string tuple. `@kbn/config-schema` doesn't do this automatically
),
before: schema.maybe(
schema.arrayOf(schema.string(), {
minSize: 2,
maxSize: 2,
}) as Type<[string, string]> // Cast this to a string tuple. `@kbn/config-schema` doesn't do this automatically
),
sort: schema.maybe(schema.string()),
order: schema.maybe(schema.oneOf([schema.literal('asc'), schema.literal('desc')])),
query: schema.maybe(
schema.string({
validate(value) {
try {
decode(value);
} catch (err) {
return i18n.translate('xpack.endpoint.alerts.errors.bad_rison', {
defaultMessage: 'must be a valid rison-encoded string',
});
}
},
})
),
// rison-encoded string
filters: schema.maybe(
schema.string({
validate(value) {
try {
decode(value);
} catch (err) {
return i18n.translate('xpack.endpoint.alerts.errors.bad_rison', {
defaultMessage: 'must be a valid rison-encoded string',
});
}
},
})
),
// rison-encoded string
date_range: schema.maybe(
schema.string({
validate(value) {
try {
decode(value);
} catch (err) {
return i18n.translate('xpack.endpoint.alerts.errors.bad_rison', {
defaultMessage: 'must be a valid rison-encoded string',
});
}
},
})
),
},
{
validate(value) {
if (value.after !== undefined && value.page_index !== undefined) {
return i18n.translate('xpack.endpoint.alerts.errors.page_index_cannot_be_used_with_after', {
defaultMessage: '[page_index] cannot be used with [after]',
});
}
if (value.before !== undefined && value.page_index !== undefined) {
return i18n.translate(
'xpack.endpoint.alerts.errors.page_index_cannot_be_used_with_before',
{
defaultMessage: '[page_index] cannot be used with [before]',
}
);
}
if (value.before !== undefined && value.after !== undefined) {
return i18n.translate('xpack.endpoint.alerts.errors.before_cannot_be_used_with_after', {
defaultMessage: '[before] cannot be used with [after]',
});
}
if (
value.before !== undefined &&
value.sort !== undefined &&
value.sort !== EndpointAppConstants.ALERT_LIST_DEFAULT_SORT
) {
return i18n.translate(
'xpack.endpoint.alerts.errors.before_cannot_be_used_with_custom_sort',
{
defaultMessage: '[before] cannot be used with custom sort',
}
);
}
},
}
);

View file

@ -1,409 +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 { SearchResponse } from 'elasticsearch';
import { TypeOf } from '@kbn/config-schema';
import { alertingIndexGetQuerySchema } from './schema/alert_index';
/**
* A deep readonly type that will make all children of a given object readonly recursively
*/
export type Immutable<T> = T extends undefined | null | boolean | string | number
? T
: T extends Array<infer U>
? ImmutableArray<U>
: T extends Map<infer K, infer V>
? ImmutableMap<K, V>
: T extends Set<infer M>
? ImmutableSet<M>
: ImmutableObject<T>;
export type ImmutableArray<T> = ReadonlyArray<Immutable<T>>;
export type ImmutableMap<K, V> = ReadonlyMap<Immutable<K>, Immutable<V>>;
export type ImmutableSet<T> = ReadonlySet<Immutable<T>>;
export type ImmutableObject<T> = { readonly [K in keyof T]: Immutable<T[K]> };
export type Direction = 'asc' | 'desc';
export class EndpointAppConstants {
static BASE_API_URL = '/api/endpoint';
static ENDPOINT_INDEX_NAME = 'endpoint-agent*';
static ALERT_INDEX_NAME = 'events-endpoint-1';
static EVENT_INDEX_NAME = 'events-endpoint-*';
static DEFAULT_TOTAL_HITS = 10000;
/**
* Legacy events are stored in indices with endgame-* prefix
*/
static LEGACY_EVENT_INDEX_NAME = 'endgame-*';
/**
* Alerts
**/
static ALERT_LIST_DEFAULT_PAGE_SIZE = 10;
static ALERT_LIST_DEFAULT_SORT = '@timestamp';
}
export interface AlertResultList {
/**
* The alerts restricted by page size.
*/
alerts: AlertData[];
/**
* The total number of alerts on the page.
*/
total: number;
/**
* The size of the requested page.
*/
request_page_size: number;
/**
* The index of the requested page, starting at 0.
*/
request_page_index?: number;
/**
* The offset of the requested page, starting at 0.
*/
result_from_index?: number;
/**
* A cursor-based URL for the next page.
*/
next: string | null;
/**
* A cursor-based URL for the previous page.
*/
prev: string | null;
}
export interface HostResultList {
/* the hosts restricted by the page size */
hosts: HostMetadata[];
/* the total number of unique hosts in the index */
total: number;
/* the page size requested */
request_page_size: number;
/* the page index requested */
request_page_index: number;
}
export interface OSFields {
full: string;
name: string;
version: string;
variant: string;
}
export interface HostFields {
id: string;
hostname: string;
ip: string[];
mac: string[];
os: OSFields;
}
export interface HashFields {
md5: string;
sha1: string;
sha256: string;
}
export interface MalwareClassifierFields {
identifier: string;
score: number;
threshold: number;
version: string;
}
export interface PrivilegesFields {
description: string;
name: string;
enabled: boolean;
}
export interface ThreadFields {
id: number;
service_name: string;
start: number;
start_address: number;
start_address_module: string;
}
export interface DllFields {
pe: {
architecture: string;
imphash: string;
};
code_signature: {
subject_name: string;
trusted: boolean;
};
compile_time: number;
hash: HashFields;
malware_classifier: MalwareClassifierFields;
mapped_address: number;
mapped_size: number;
path: string;
}
/**
* Describes an Alert Event.
* Should be in line with ECS schema.
*/
export type AlertEvent = Immutable<{
'@timestamp': number;
agent: {
id: string;
version: string;
};
event: {
id: string;
action: string;
category: string;
kind: string;
dataset: string;
module: string;
type: string;
};
endpoint: {
policy: {
id: string;
};
};
process: {
code_signature: {
subject_name: string;
trusted: boolean;
};
command_line?: string;
domain?: string;
pid: number;
ppid?: number;
entity_id: string;
parent?: {
pid: number;
entity_id: string;
};
name: string;
hash: HashFields;
pe?: {
imphash: string;
};
executable: string;
sid?: string;
start: number;
malware_classifier?: MalwareClassifierFields;
token: {
domain: string;
type: string;
user: string;
sid: string;
integrity_level: number;
integrity_level_name: string;
privileges?: PrivilegesFields[];
};
thread?: ThreadFields[];
uptime: number;
user: string;
};
file: {
owner: string;
name: string;
path: string;
accessed: number;
mtime: number;
created: number;
size: number;
hash: HashFields;
pe?: {
imphash: string;
};
code_signature: {
trusted: boolean;
subject_name: string;
};
malware_classifier: MalwareClassifierFields;
temp_file_path: string;
};
host: HostFields;
dll?: DllFields[];
}>;
interface AlertMetadata {
id: string;
// Alert Details Pagination
next: string | null;
prev: string | null;
}
/**
* Union of alert data and metadata.
*/
export type AlertData = AlertEvent & AlertMetadata;
export type HostMetadata = Immutable<{
'@timestamp': number;
event: {
created: number;
};
endpoint: {
policy: {
id: string;
};
};
agent: {
id: string;
version: string;
};
host: HostFields;
}>;
/**
* Represents `total` response from Elasticsearch after ES 7.0.
*/
export interface ESTotal {
value: number;
relation: string;
}
/**
* `Hits` array in responses from ES search API.
*/
export type AlertHits = SearchResponse<AlertEvent>['hits']['hits'];
export interface LegacyEndpointEvent {
'@timestamp': number;
endgame: {
pid?: number;
ppid?: number;
event_type_full?: string;
event_subtype_full?: string;
event_timestamp?: number;
event_type?: number;
unique_pid: number;
unique_ppid?: number;
machine_id?: string;
process_name?: string;
process_path?: string;
timestamp_utc?: string;
serial_event_id?: number;
};
agent: {
id: string;
type: string;
version: string;
};
process?: object;
rule?: object;
user?: object;
}
export interface EndpointEvent {
'@timestamp': number;
agent: {
id: string;
version: string;
type: string;
};
ecs: {
version: string;
};
event: {
category: string | string[];
type: string | string[];
id: string;
kind: string;
};
host: {
id: string;
hostname: string;
ip: string[];
mac: string[];
os: OSFields;
};
process: {
entity_id: string;
name: string;
parent?: {
entity_id: string;
name?: string;
};
};
}
export type ResolverEvent = EndpointEvent | LegacyEndpointEvent;
/**
* The PageId type is used for the payload when firing userNavigatedToPage actions
*/
export type PageId = 'alertsPage' | 'managementPage' | 'policyListPage';
/**
* Takes a @kbn/config-schema 'schema' type and returns a type that represents valid inputs.
* Similar to `TypeOf`, but allows strings as input for `schema.number()` (which is inline
* with the behavior of the validator.) Also, for `schema.object`, when a value is a `schema.maybe`
* the key will be marked optional (via `?`) so that you can omit keys for optional values.
*
* Use this when creating a value that will be passed to the schema.
* e.g.
* ```ts
* const input: KbnConfigSchemaInputTypeOf<typeof schema> = value
* schema.validate(input) // should be valid
* ```
* Note that because the types coming from `@kbn/config-schema`'s schemas sometimes have deeply nested
* `Type` types, we process the result of `TypeOf` instead, as this will be consistent.
*/
type KbnConfigSchemaInputTypeOf<T> = T extends Record<string, unknown>
? KbnConfigSchemaInputObjectTypeOf<
T
> /** `schema.number()` accepts strings, so this type should accept them as well. */
: number extends T
? T | string
: T;
/**
* Works like ObjectResultType, except that 'maybe' schema will create an optional key.
* This allows us to avoid passing 'maybeKey: undefined' when constructing such an object.
*
* Instead of using this directly, use `InputTypeOf`.
*/
type KbnConfigSchemaInputObjectTypeOf<P extends Record<string, unknown>> = {
/** Use ? to make the field optional if the prop accepts undefined.
* This allows us to avoid writing `field: undefined` for optional fields.
*/
[K in Exclude<keyof P, keyof KbnConfigSchemaNonOptionalProps<P>>]?: KbnConfigSchemaInputTypeOf<
P[K]
>;
} &
{ [K in keyof KbnConfigSchemaNonOptionalProps<P>]: KbnConfigSchemaInputTypeOf<P[K]> };
/**
* Takes the props of a schema.object type, and returns a version that excludes
* optional values. Used by `InputObjectTypeOf`.
*
* Instead of using this directly, use `InputTypeOf`.
*/
type KbnConfigSchemaNonOptionalProps<Props extends Record<string, unknown>> = Pick<
Props,
{
[Key in keyof Props]: undefined extends Props[Key]
? never
: null extends Props[Key]
? never
: Key;
}[keyof Props]
>;
/**
* Query params to pass to the alert API when fetching new data.
*/
export type AlertingIndexGetQueryInput = KbnConfigSchemaInputTypeOf<
TypeOf<typeof alertingIndexGetQuerySchema>
>;
/**
* Result of the validated query params when handling alert index requests.
*/
export type AlertingIndexGetQueryResult = TypeOf<typeof alertingIndexGetQuerySchema>;

View file

@ -1,9 +0,0 @@
{
"id": "endpoint",
"version": "1.0.0",
"kibanaVersion": "kibana",
"configPath": ["xpack", "endpoint"],
"requiredPlugins": ["features", "embeddable", "data", "dataEnhanced"],
"server": true,
"ui": true
}

View file

@ -1,18 +0,0 @@
{
"author": "Elastic",
"name": "endpoint",
"version": "0.0.0",
"private": true,
"license": "Elastic-License",
"scripts": {
"test:generate": "ts-node --project scripts/cli_tsconfig.json scripts/resolver_generator.ts"
},
"dependencies": {
"react-redux": "^7.1.0"
},
"devDependencies": {
"@types/seedrandom": ">=2.0.0 <4.0.0",
"@types/react-redux": "^7.1.0",
"redux-devtools-extension": "^2.13.8"
}
}

View file

@ -1,75 +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 React, { MouseEvent } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiTabs, EuiTab } from '@elastic/eui';
import { useHistory, useLocation } from 'react-router-dom';
export interface NavTabs {
name: string;
id: string;
href: string;
}
export const navTabs: NavTabs[] = [
{
id: 'home',
name: i18n.translate('xpack.endpoint.headerNav.home', {
defaultMessage: 'Home',
}),
href: '/',
},
{
id: 'hosts',
name: i18n.translate('xpack.endpoint.headerNav.hosts', {
defaultMessage: 'Hosts',
}),
href: '/hosts',
},
{
id: 'alerts',
name: i18n.translate('xpack.endpoint.headerNav.alerts', {
defaultMessage: 'Alerts',
}),
href: '/alerts',
},
{
id: 'policies',
name: i18n.translate('xpack.endpoint.headerNav.policies', {
defaultMessage: 'Policies',
}),
href: '/policy',
},
];
export const HeaderNavigation: React.FunctionComponent<{ basename: string }> = React.memo(
({ basename }) => {
const history = useHistory();
const location = useLocation();
function renderNavTabs(tabs: NavTabs[]) {
return tabs.map((tab, index) => {
return (
<EuiTab
data-test-subj={`${tab.id}EndpointTab`}
key={index}
href={`${basename}${tab.href}`}
onClick={(event: MouseEvent) => {
event.preventDefault();
history.push(tab.href);
}}
isSelected={tab.href === location.pathname}
>
{tab.name}
</EuiTab>
);
});
}
return <EuiTabs>{renderNavTabs(navTabs)}</EuiTabs>;
}
);

View file

@ -1,13 +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 styled from 'styled-components';
export const TruncateText = styled.div`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`;

View file

@ -1,103 +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 * as React from 'react';
import ReactDOM from 'react-dom';
import { CoreStart, AppMountParameters } from 'kibana/public';
import { I18nProvider, FormattedMessage } from '@kbn/i18n/react';
import { Route, Switch, BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import { useObservable } from 'react-use';
import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public';
import { RouteCapture } from './view/route_capture';
import { EndpointPluginStartDependencies } from '../../plugin';
import { appStoreFactory } from './store';
import { AlertIndex } from './view/alerts';
import { HostList } from './view/hosts';
import { PolicyList } from './view/policy';
import { PolicyDetails } from './view/policy';
import { HeaderNavigation } from './components/header_nav';
import { EuiThemeProvider } from '../../../../../legacy/common/eui_styled_components';
/**
* This module will be loaded asynchronously to reduce the bundle size of your plugin's main bundle.
*/
export function renderApp(
coreStart: CoreStart,
depsStart: EndpointPluginStartDependencies,
{ appBasePath, element }: AppMountParameters
) {
coreStart.http.get('/api/endpoint/hello-world');
const store = appStoreFactory({ coreStart, depsStart });
ReactDOM.render(
<AppRoot basename={appBasePath} store={store} coreStart={coreStart} depsStart={depsStart} />,
element
);
return () => {
ReactDOM.unmountComponentAtNode(element);
};
}
interface RouterProps {
basename: string;
store: Store;
coreStart: CoreStart;
depsStart: EndpointPluginStartDependencies;
}
const AppRoot: React.FunctionComponent<RouterProps> = React.memo(
({
basename,
store,
coreStart: { http, notifications, uiSettings, application },
depsStart: { data },
}) => {
const isDarkMode = useObservable<boolean>(uiSettings.get$('theme:darkMode'));
return (
<Provider store={store}>
<I18nProvider>
<KibanaContextProvider services={{ http, notifications, application, data }}>
<EuiThemeProvider darkMode={isDarkMode}>
<BrowserRouter basename={basename}>
<RouteCapture>
<HeaderNavigation basename={basename} />
<Switch>
<Route
exact
path="/"
render={() => (
<h1 data-test-subj="welcomeTitle">
<FormattedMessage
id="xpack.endpoint.welcomeTitle"
defaultMessage="Hello World"
/>
</h1>
)}
/>
<Route path="/hosts" component={HostList} />
<Route path="/alerts" component={AlertIndex} />
<Route path="/policy" exact component={PolicyList} />
<Route path="/policy/:id" exact component={PolicyDetails} />
<Route
render={() => (
<FormattedMessage
id="xpack.endpoint.notFound"
defaultMessage="Page Not Found"
/>
)}
/>
</Switch>
</RouteCapture>
</BrowserRouter>
</EuiThemeProvider>
</KibanaContextProvider>
</I18nProvider>
</Provider>
);
}
);

View file

@ -1,7 +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.
*/
export * from './saga';

View file

@ -1,114 +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 { createSagaMiddleware, SagaContext, SagaMiddleware } from './index';
import { applyMiddleware, createStore, Reducer, Store } from 'redux';
describe('saga', () => {
const INCREMENT_COUNTER = 'INCREMENT';
const DELAYED_INCREMENT_COUNTER = 'DELAYED INCREMENT COUNTER';
const STOP_SAGA_PROCESSING = 'BREAK ASYNC ITERATOR';
const sleep = (ms = 100) => new Promise(resolve => setTimeout(resolve, ms));
let store: Store;
let reducerA: Reducer;
let sideAffect: (a: unknown, s: unknown) => void;
let sagaExe: (sagaContext: SagaContext) => Promise<void>;
let sagaExeReduxMiddleware: SagaMiddleware;
beforeEach(() => {
reducerA = jest.fn((prevState = { count: 0 }, { type }) => {
switch (type) {
case INCREMENT_COUNTER:
return { ...prevState, count: prevState.count + 1 };
default:
return prevState;
}
});
sideAffect = jest.fn();
sagaExe = jest.fn(async ({ actionsAndState, dispatch }: SagaContext) => {
for await (const { action, state } of actionsAndState()) {
expect(action).toBeDefined();
expect(state).toBeDefined();
if (action.type === STOP_SAGA_PROCESSING) {
break;
}
sideAffect(action, state);
if (action.type === DELAYED_INCREMENT_COUNTER) {
await sleep(1);
dispatch({
type: INCREMENT_COUNTER,
});
}
}
});
sagaExeReduxMiddleware = createSagaMiddleware(sagaExe);
store = createStore(reducerA, applyMiddleware(sagaExeReduxMiddleware));
});
afterEach(() => {
sagaExeReduxMiddleware.stop();
});
test('it does nothing if saga is not started', () => {
expect(sagaExe).not.toHaveBeenCalled();
});
test('it can dispatch store actions once running', async () => {
sagaExeReduxMiddleware.start();
expect(store.getState()).toEqual({ count: 0 });
expect(sagaExe).toHaveBeenCalled();
store.dispatch({ type: DELAYED_INCREMENT_COUNTER });
expect(store.getState()).toEqual({ count: 0 });
await sleep();
expect(sideAffect).toHaveBeenCalled();
expect(store.getState()).toEqual({ count: 1 });
});
test('it stops processing if break out of loop', async () => {
sagaExeReduxMiddleware.start();
store.dispatch({ type: DELAYED_INCREMENT_COUNTER });
await sleep();
expect(store.getState()).toEqual({ count: 1 });
expect(sideAffect).toHaveBeenCalledTimes(2);
store.dispatch({ type: STOP_SAGA_PROCESSING });
await sleep();
store.dispatch({ type: DELAYED_INCREMENT_COUNTER });
await sleep();
expect(store.getState()).toEqual({ count: 1 });
expect(sideAffect).toHaveBeenCalledTimes(2);
});
test('it stops saga middleware when stop() is called', async () => {
sagaExeReduxMiddleware.start();
store.dispatch({ type: DELAYED_INCREMENT_COUNTER });
await sleep();
expect(store.getState()).toEqual({ count: 1 });
expect(sideAffect).toHaveBeenCalledTimes(2);
sagaExeReduxMiddleware.stop();
store.dispatch({ type: DELAYED_INCREMENT_COUNTER });
await sleep();
expect(store.getState()).toEqual({ count: 1 });
expect(sideAffect).toHaveBeenCalledTimes(2);
});
});

View file

@ -1,159 +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 { AnyAction, Dispatch, Middleware, MiddlewareAPI } from 'redux';
import { GlobalState } from '../types';
interface QueuedAction<TAction = AnyAction> {
/**
* The Redux action that was dispatched
*/
action: TAction;
/**
* The Global state at the time the action was dispatched
*/
state: GlobalState;
}
interface IteratorInstance {
queue: QueuedAction[];
nextResolve: null | ((inst: QueuedAction) => void);
}
type Saga = (storeContext: SagaContext) => Promise<void>;
type StoreActionsAndState<TAction = AnyAction> = AsyncIterableIterator<QueuedAction<TAction>>;
export interface SagaContext<TAction extends AnyAction = AnyAction> {
/**
* A generator function that will `yield` `Promise`s that resolve with a `QueuedAction`
*/
actionsAndState: () => StoreActionsAndState<TAction>;
dispatch: Dispatch<TAction>;
}
export interface SagaMiddleware extends Middleware {
/**
* Start the saga. Should be called after the `store` has been created
*/
start: () => void;
/**
* Stop the saga by exiting the internal generator `for await...of` loop.
*/
stop: () => void;
}
const noop = () => {};
const STOP = Symbol('STOP');
/**
* Creates Saga Middleware for use with Redux.
*
* @param {Saga} saga The `saga` should initialize a long-running `for await...of` loop against
* the return value of the `actionsAndState()` method provided by the `SagaContext`.
*
* @return {SagaMiddleware}
*
* @example
*
* type TPossibleActions = { type: 'add', payload: any[] };
* //...
* const endpointsSaga = async ({ actionsAndState, dispatch }: SagaContext<TPossibleActions>) => {
* for await (const { action, state } of actionsAndState()) {
* if (action.type === "userRequestedResource") {
* const resourceData = await doApiFetch('of/some/resource');
* dispatch({
* type: 'add',
* payload: [ resourceData ]
* });
* }
* }
* }
* const endpointsSagaMiddleware = createSagaMiddleware(endpointsSaga);
* //....
* const store = createStore(reducers, [ endpointsSagaMiddleware ]);
*/
export function createSagaMiddleware(saga: Saga): SagaMiddleware {
const iteratorInstances = new Set<IteratorInstance>();
let runSaga: () => void = noop;
let stopSaga: () => void = noop;
let runningPromise: Promise<symbol>;
async function* getActionsAndStateIterator(): StoreActionsAndState {
const instance: IteratorInstance = { queue: [], nextResolve: null };
iteratorInstances.add(instance);
try {
while (true) {
const actionAndState = await Promise.race([nextActionAndState(), runningPromise]);
if (actionAndState === STOP) {
break;
}
yield actionAndState as QueuedAction;
}
} finally {
// If the consumer stops consuming this (e.g. `break` or `return` is called in the `for await`
// then this `finally` block will run and unregister this instance and reset `runSaga`
iteratorInstances.delete(instance);
runSaga = stopSaga = noop;
}
function nextActionAndState() {
if (instance.queue.length) {
return Promise.resolve(instance.queue.shift() as QueuedAction);
} else {
return new Promise<QueuedAction>(function(resolve) {
instance.nextResolve = resolve;
});
}
}
}
function enqueue(value: QueuedAction) {
for (const iteratorInstance of iteratorInstances) {
iteratorInstance.queue.push(value);
if (iteratorInstance.nextResolve !== null) {
iteratorInstance.nextResolve(iteratorInstance.queue.shift() as QueuedAction);
iteratorInstance.nextResolve = null;
}
}
}
function middleware({ getState, dispatch }: MiddlewareAPI) {
if (runSaga === noop) {
runSaga = saga.bind<null, SagaContext, any[], Promise<void>>(null, {
actionsAndState: getActionsAndStateIterator,
dispatch,
});
}
return (next: Dispatch<AnyAction>) => (action: AnyAction) => {
// Call the next dispatch method in the middleware chain.
const returnValue = next(action);
enqueue({
action,
state: getState(),
});
// This will likely be the action itself, unless a middleware further in chain changed it.
return returnValue;
};
}
middleware.start = () => {
runningPromise = new Promise(resolve => (stopSaga = () => resolve(STOP)));
runSaga();
};
middleware.stop = () => {
stopSaga();
};
return middleware;
}

View file

@ -1,58 +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 {
dataPluginMock,
Start as DataPublicStartMock,
} from '../../../../../../src/plugins/data/public/mocks';
type DataMock = Omit<DataPublicStartMock, 'indexPatterns' | 'query'> & {
indexPatterns: Omit<DataPublicStartMock['indexPatterns'], 'getFieldsForWildcard'> & {
getFieldsForWildcard: jest.Mock;
};
// We can't Omit (override) 'query' here because FilterManager is a class not an interface.
// Because of this, wherever FilterManager is used tsc expects some FilterManager private fields
// like filters, updated$, fetch$ to be part of the type. Omit removes these private fields when used.
query: DataPublicStartMock['query'] & {
filterManager: {
setFilters: jest.Mock;
getUpdates$: jest.Mock;
};
};
ui: DataPublicStartMock['ui'] & {
SearchBar: jest.Mock;
};
};
/**
* Type for our app's depsStart (plugin start dependencies)
*/
export interface DepsStartMock {
data: DataMock;
}
/**
* Returns a mock of our app's depsStart (plugin start dependencies)
*/
export const depsStartMock: () => DepsStartMock = () => {
const dataMock: DataMock = (dataPluginMock.createStartContract() as unknown) as DataMock;
dataMock.indexPatterns.getFieldsForWildcard = jest.fn();
dataMock.query.filterManager.setFilters = jest.fn();
dataMock.query.filterManager.getUpdates$ = jest.fn(() => {
return {
subscribe: jest.fn(() => {
return {
unsubscribe: jest.fn(),
};
}),
};
}) as DataMock['query']['filterManager']['getUpdates$'];
dataMock.ui.SearchBar = jest.fn();
return {
data: dataMock,
};
};

View file

@ -1,53 +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 { httpServiceMock } from '../../../../../../../src/core/public/mocks';
import { sendGetDatasource, sendGetEndpointSpecificDatasources } from './ingest';
describe('ingest service', () => {
let http: ReturnType<typeof httpServiceMock.createStartContract>;
beforeEach(() => {
http = httpServiceMock.createStartContract();
});
describe('sendGetEndpointSpecificDatasources()', () => {
it('auto adds kuery to api request', async () => {
await sendGetEndpointSpecificDatasources(http);
expect(http.get).toHaveBeenCalledWith('/api/ingest_manager/datasources', {
query: {
kuery: 'datasources.package.name: endpoint',
},
});
});
it('supports additional KQL to be defined on input for query params', async () => {
await sendGetEndpointSpecificDatasources(http, {
query: { kuery: 'someValueHere', page: 1, perPage: 10 },
});
expect(http.get).toHaveBeenCalledWith('/api/ingest_manager/datasources', {
query: {
kuery: 'someValueHere and datasources.package.name: endpoint',
perPage: 10,
page: 1,
},
});
});
});
describe('sendGetDatasource()', () => {
it('builds correct API path', async () => {
await sendGetDatasource(http, '123');
expect(http.get).toHaveBeenCalledWith('/api/ingest_manager/datasources/123', undefined);
});
it('supports http options', async () => {
await sendGetDatasource(http, '123', { query: { page: 1 } });
expect(http.get).toHaveBeenCalledWith('/api/ingest_manager/datasources/123', {
query: {
page: 1,
},
});
});
});
});

View file

@ -1,62 +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 { HttpFetchOptions, HttpStart } from 'kibana/public';
import { GetDatasourcesRequest } from '../../../../../ingest_manager/common/types/rest_spec';
import { PolicyData } from '../types';
const INGEST_API_ROOT = `/api/ingest_manager`;
const INGEST_API_DATASOURCES = `${INGEST_API_ROOT}/datasources`;
// FIXME: Import from ingest after - https://github.com/elastic/kibana/issues/60677
export interface GetDatasourcesResponse {
items: PolicyData[];
total: number;
page: number;
perPage: number;
success: boolean;
}
// FIXME: Import from Ingest after - https://github.com/elastic/kibana/issues/60677
export interface GetDatasourceResponse {
item: PolicyData;
success: boolean;
}
/**
* Retrieves a list of endpoint specific datasources (those created with a `package.name` of
* `endpoint`) from Ingest
* @param http
* @param options
*/
export const sendGetEndpointSpecificDatasources = (
http: HttpStart,
options: HttpFetchOptions & Partial<GetDatasourcesRequest> = {}
): Promise<GetDatasourcesResponse> => {
return http.get<GetDatasourcesResponse>(INGEST_API_DATASOURCES, {
...options,
query: {
...options.query,
kuery: `${
options?.query?.kuery ? options.query.kuery + ' and ' : ''
}datasources.package.name: endpoint`,
},
});
};
/**
* Retrieves a single datasource based on ID from ingest
* @param http
* @param datasourceId
* @param options
*/
export const sendGetDatasource = (
http: HttpStart,
datasourceId: string,
options?: HttpFetchOptions
) => {
return http.get<GetDatasourceResponse>(`${INGEST_API_DATASOURCES}/${datasourceId}`, options);
};

View file

@ -1,18 +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 { HostAction } from './hosts';
import { AlertAction } from './alerts';
import { RoutingAction } from './routing';
import { PolicyListAction } from './policy_list';
import { PolicyDetailsAction } from './policy_details';
export type AppAction =
| HostAction
| AlertAction
| RoutingAction
| PolicyListAction
| PolicyDetailsAction;

View file

@ -1,29 +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 { IIndexPattern } from 'src/plugins/data/public';
import { Immutable, AlertData } from '../../../../../common/types';
import { AlertListData } from '../../types';
interface ServerReturnedAlertsData {
readonly type: 'serverReturnedAlertsData';
readonly payload: Immutable<AlertListData>;
}
interface ServerReturnedAlertDetailsData {
readonly type: 'serverReturnedAlertDetailsData';
readonly payload: Immutable<AlertData>;
}
interface ServerReturnedSearchBarIndexPatterns {
type: 'serverReturnedSearchBarIndexPatterns';
payload: IIndexPattern[];
}
export type AlertAction =
| ServerReturnedAlertsData
| ServerReturnedAlertDetailsData
| ServerReturnedSearchBarIndexPatterns;

View file

@ -1,69 +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, createStore, applyMiddleware } from 'redux';
import { History } from 'history';
import { alertListReducer } from './reducer';
import { AlertListState } from '../../types';
import { alertMiddlewareFactory } from './middleware';
import { AppAction } from '../action';
import { coreMock } from 'src/core/public/mocks';
import { DepsStartMock, depsStartMock } from '../../mocks';
import { createBrowserHistory } from 'history';
import { mockAlertResultList } from './mock_alert_result_list';
describe('alert details tests', () => {
let store: Store<AlertListState, AppAction>;
let coreStart: ReturnType<typeof coreMock.createStart>;
let depsStart: DepsStartMock;
let history: History<never>;
/**
* A function that waits until a selector returns true.
*/
let selectorIsTrue: (selector: (state: AlertListState) => boolean) => Promise<void>;
beforeEach(() => {
coreStart = coreMock.createStart();
depsStart = depsStartMock();
history = createBrowserHistory();
const middleware = alertMiddlewareFactory(coreStart, depsStart);
store = createStore(alertListReducer, applyMiddleware(middleware));
selectorIsTrue = async selector => {
// If the selector returns true, we're done
while (selector(store.getState()) !== true) {
// otherwise, wait til the next state change occurs
await new Promise(resolve => {
const unsubscribe = store.subscribe(() => {
unsubscribe();
resolve();
});
});
}
};
});
describe('when the user is on the alert list page with a selected alert in the url', () => {
beforeEach(() => {
const firstResponse: Promise<unknown> = Promise.resolve(mockAlertResultList());
coreStart.http.get.mockReturnValue(firstResponse);
depsStart.data.indexPatterns.getFieldsForWildcard.mockReturnValue(Promise.resolve([]));
// Simulates user navigating to the /alerts page
store.dispatch({
type: 'userChangedUrl',
payload: {
...history.location,
pathname: '/alerts',
search: '?selected_alert=q9ncfh4q9ctrmc90umcq4',
},
});
});
it('should return alert details data', async () => {
// wait for alertDetails to be defined
await selectorIsTrue(state => state.alertDetails !== undefined);
});
});
});

View file

@ -1,76 +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, createStore, applyMiddleware } from 'redux';
import { History } from 'history';
import { alertListReducer } from './reducer';
import { AlertListState } from '../../types';
import { alertMiddlewareFactory } from './middleware';
import { AppAction } from '../action';
import { coreMock } from 'src/core/public/mocks';
import { DepsStartMock, depsStartMock } from '../../mocks';
import { AlertResultList } from '../../../../../common/types';
import { isOnAlertPage } from './selectors';
import { createBrowserHistory } from 'history';
import { mockAlertResultList } from './mock_alert_result_list';
describe('alert list tests', () => {
let store: Store<AlertListState, AppAction>;
let coreStart: ReturnType<typeof coreMock.createStart>;
let depsStart: DepsStartMock;
let history: History<never>;
/**
* A function that waits until a selector returns true.
*/
let selectorIsTrue: (selector: (state: AlertListState) => boolean) => Promise<void>;
beforeEach(() => {
coreStart = coreMock.createStart();
depsStart = depsStartMock();
history = createBrowserHistory();
const middleware = alertMiddlewareFactory(coreStart, depsStart);
store = createStore(alertListReducer, applyMiddleware(middleware));
selectorIsTrue = async selector => {
// If the selector returns true, we're done
while (selector(store.getState()) !== true) {
// otherwise, wait til the next state change occurs
await new Promise(resolve => {
const unsubscribe = store.subscribe(() => {
unsubscribe();
resolve();
});
});
}
};
});
describe('when the user navigates to the alert list page', () => {
beforeEach(() => {
coreStart.http.get.mockImplementation(async () => {
const response: AlertResultList = mockAlertResultList();
return response;
});
depsStart.data.indexPatterns.getFieldsForWildcard.mockReturnValue(Promise.resolve([]));
// Simulates user navigating to the /alerts page
store.dispatch({
type: 'userChangedUrl',
payload: {
...history.location,
pathname: '/alerts',
},
});
});
it("should recognize it's on the alert list page", () => {
const actual = isOnAlertPage(store.getState());
expect(actual).toBe(true);
});
it('should return alertListData', async () => {
await selectorIsTrue(state => state.alerts.length === 1);
});
});
});

View file

@ -1,88 +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, createStore, applyMiddleware } from 'redux';
import { History } from 'history';
import { alertListReducer } from './reducer';
import { AlertListState, AlertingIndexUIQueryParams } from '../../types';
import { alertMiddlewareFactory } from './middleware';
import { AppAction } from '../action';
import { coreMock } from 'src/core/public/mocks';
import { DepsStartMock, depsStartMock } from '../../mocks';
import { createBrowserHistory } from 'history';
import { uiQueryParams } from './selectors';
import { urlFromQueryParams } from '../../view/alerts/url_from_query_params';
describe('alert list pagination', () => {
let store: Store<AlertListState, AppAction>;
let coreStart: ReturnType<typeof coreMock.createStart>;
let depsStart: DepsStartMock;
let history: History<never>;
let queryParams: () => AlertingIndexUIQueryParams;
/**
* Update the history with a new `AlertingIndexUIQueryParams`
*/
let historyPush: (params: AlertingIndexUIQueryParams) => void;
beforeEach(() => {
coreStart = coreMock.createStart();
depsStart = depsStartMock();
history = createBrowserHistory();
const middleware = alertMiddlewareFactory(coreStart, depsStart);
store = createStore(alertListReducer, applyMiddleware(middleware));
history.listen(location => {
store.dispatch({ type: 'userChangedUrl', payload: location });
});
queryParams = () => uiQueryParams(store.getState());
historyPush = (nextQueryParams: AlertingIndexUIQueryParams): void => {
return history.push(urlFromQueryParams(nextQueryParams));
};
});
describe('when the user navigates to the alert list page', () => {
describe('when a new page size is passed', () => {
beforeEach(() => {
historyPush({ ...queryParams(), page_size: '1' });
});
it('should modify the url correctly', () => {
expect(queryParams()).toMatchInlineSnapshot(`
Object {
"page_size": "1",
}
`);
});
describe('and then a new page index is passed', () => {
beforeEach(() => {
historyPush({ ...queryParams(), page_index: '1' });
});
it('should modify the url in the correct order', () => {
expect(queryParams()).toMatchInlineSnapshot(`
Object {
"page_index": "1",
"page_size": "1",
}
`);
});
});
});
describe('when a new page index is passed', () => {
beforeEach(() => {
historyPush({ ...queryParams(), page_index: '1' });
});
it('should modify the url correctly', () => {
expect(queryParams()).toMatchInlineSnapshot(`
Object {
"page_index": "1",
}
`);
});
});
});
});

View file

@ -1,9 +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.
*/
export { alertListReducer } from './reducer';
export { AlertAction } from './action';
export * from '../../types';

View file

@ -1,49 +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 { IIndexPattern } from 'src/plugins/data/public';
import { AlertResultList, AlertData } from '../../../../../common/types';
import { AppAction } from '../action';
import { MiddlewareFactory, AlertListState } from '../../types';
import { isOnAlertPage, apiQueryParams, hasSelectedAlert, uiQueryParams } from './selectors';
import { cloneHttpFetchQuery } from '../../../../common/clone_http_fetch_query';
import { EndpointAppConstants } from '../../../../../common/types';
export const alertMiddlewareFactory: MiddlewareFactory<AlertListState> = (coreStart, depsStart) => {
async function fetchIndexPatterns(): Promise<IIndexPattern[]> {
const { indexPatterns } = depsStart.data;
const indexName = EndpointAppConstants.ALERT_INDEX_NAME;
const fields = await indexPatterns.getFieldsForWildcard({ pattern: indexName });
const indexPattern: IIndexPattern = {
title: indexName,
fields,
};
return [indexPattern];
}
return api => next => async (action: AppAction) => {
next(action);
const state = api.getState();
if (action.type === 'userChangedUrl' && isOnAlertPage(state)) {
const patterns = await fetchIndexPatterns();
api.dispatch({ type: 'serverReturnedSearchBarIndexPatterns', payload: patterns });
const response: AlertResultList = await coreStart.http.get(`/api/endpoint/alerts`, {
query: cloneHttpFetchQuery(apiQueryParams(state)),
});
api.dispatch({ type: 'serverReturnedAlertsData', payload: response });
}
if (action.type === 'userChangedUrl' && isOnAlertPage(state) && hasSelectedAlert(state)) {
const uiParams = uiQueryParams(state);
const response: AlertData = await coreStart.http.get(
`/api/endpoint/alerts/${uiParams.selected_alert}`
);
api.dispatch({ type: 'serverReturnedAlertDetailsData', payload: response });
}
};
};

View file

@ -1,49 +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 { AlertResultList } from '../../../../../common/types';
import { EndpointDocGenerator } from '../../../../../common/generate_data';
export const mockAlertResultList: (options?: {
total?: number;
request_page_size?: number;
request_page_index?: number;
}) => AlertResultList = (options = {}) => {
const {
total = 1,
request_page_size: requestPageSize = 10,
request_page_index: requestPageIndex = 0,
} = options;
// Skip any that are before the page we're on
const numberToSkip = requestPageSize * requestPageIndex;
// total - numberToSkip is the count of non-skipped ones, but return no more than a pageSize, and no less than 0
const actualCountToReturn = Math.max(Math.min(total - numberToSkip, requestPageSize), 0);
const alerts = [];
const generator = new EndpointDocGenerator();
for (let index = 0; index < actualCountToReturn; index++) {
alerts.push({
...generator.generateAlert(new Date().getTime() + index * 1000),
...{
id: 'xDUYMHABAJk0XnHd8rrd' + index,
prev: null,
next: null,
},
});
}
const mock: AlertResultList = {
alerts,
total,
request_page_size: requestPageSize,
request_page_index: requestPageIndex,
next: '/api/endpoint/alerts?after=1542341895000&after=2f1c0928-3876-4e11-acbb-9199257c7b1c',
prev: '/api/endpoint/alerts?before=1542341895000&before=2f1c0928-3876-4e11-acbb-9199257c7b1c',
result_from_index: 0,
};
return mock;
};

View file

@ -1,66 +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 { Reducer } from 'redux';
import { AlertListState } from '../../types';
import { AppAction } from '../action';
const initialState = (): AlertListState => {
return {
alerts: [],
alertDetails: undefined,
pageSize: 10,
pageIndex: 0,
total: 0,
location: undefined,
searchBar: {
patterns: [],
},
};
};
export const alertListReducer: Reducer<AlertListState, AppAction> = (
state = initialState(),
action
) => {
if (action.type === 'serverReturnedAlertsData') {
const {
alerts,
request_page_size: pageSize,
request_page_index: pageIndex,
total,
} = action.payload;
return {
...state,
alerts,
pageSize,
// request_page_index is optional because right now we support both
// simple and cursor based pagination.
pageIndex: pageIndex || 0,
total,
};
} else if (action.type === 'userChangedUrl') {
return {
...state,
location: action.payload,
};
} else if (action.type === 'serverReturnedAlertDetailsData') {
return {
...state,
alertDetails: action.payload,
};
} else if (action.type === 'serverReturnedSearchBarIndexPatterns') {
return {
...state,
searchBar: {
...state.searchBar,
patterns: action.payload,
},
};
}
return state;
};

View file

@ -1,167 +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 querystring from 'querystring';
import {
createSelector,
createStructuredSelector as createStructuredSelectorWithBadType,
} from 'reselect';
import { encode, decode } from 'rison-node';
import { Query, TimeRange, Filter } from 'src/plugins/data/public';
import { AlertListState, AlertingIndexUIQueryParams, CreateStructuredSelector } from '../../types';
import { Immutable, AlertingIndexGetQueryInput } from '../../../../../common/types';
const createStructuredSelector: CreateStructuredSelector = createStructuredSelectorWithBadType;
/**
* Returns the Alert Data array from state
*/
export const alertListData = (state: AlertListState) => state.alerts;
export const selectedAlertDetailsData = (state: AlertListState) => state.alertDetails;
/**
* Returns the alert list pagination data from state
*/
export const alertListPagination = createStructuredSelector({
pageIndex: (state: AlertListState) => state.pageIndex,
pageSize: (state: AlertListState) => state.pageSize,
total: (state: AlertListState) => state.total,
});
/**
* Returns a boolean based on whether or not the user is on the alerts page
*/
export const isOnAlertPage = (state: AlertListState): boolean => {
return state.location ? state.location.pathname === '/alerts' : false;
};
/**
* Returns the query object received from parsing the browsers URL query params.
* Used to calculate urls for links and such.
*/
export const uiQueryParams: (
state: AlertListState
) => Immutable<AlertingIndexUIQueryParams> = createSelector(
(state: AlertListState) => state.location,
(location: AlertListState['location']) => {
const data: AlertingIndexUIQueryParams = {};
if (location) {
// Removes the `?` from the beginning of query string if it exists
const query = querystring.parse(location.search.slice(1));
/**
* Build an AlertingIndexUIQueryParams object with keys from the query.
* If more than one value exists for a key, use the last.
*/
const keys: Array<keyof AlertingIndexUIQueryParams> = [
'page_size',
'page_index',
'selected_alert',
'query',
'date_range',
'filters',
];
for (const key of keys) {
const value = query[key];
if (typeof value === 'string') {
data[key] = value;
} else if (Array.isArray(value)) {
data[key] = value[value.length - 1];
}
}
}
return data;
}
);
/**
* Parses the ui query params and returns a object that represents the query used by the SearchBar component.
* If the query url param is undefined, a default is returned.
*/
export const searchBarQuery: (state: AlertListState) => Query = createSelector(
uiQueryParams,
({ query }) => {
if (query !== undefined) {
return (decode(query) as unknown) as Query;
} else {
return { query: '', language: 'kuery' };
}
}
);
/**
* Parses the ui query params and returns a rison encoded string that represents the search bar's date range.
* A default is provided if 'date_range' is not present in the url params.
*/
export const encodedSearchBarDateRange: (state: AlertListState) => string = createSelector(
uiQueryParams,
({ date_range: dateRange }) => {
if (dateRange === undefined) {
return encode({ from: 'now-24h', to: 'now' });
} else {
return dateRange;
}
}
);
/**
* Parses the ui query params and returns a object that represents the dateRange used by the SearchBar component.
*/
export const searchBarDateRange: (state: AlertListState) => TimeRange = createSelector(
encodedSearchBarDateRange,
encodedDateRange => {
return (decode(encodedDateRange) as unknown) as TimeRange;
}
);
/**
* Parses the ui query params and returns an array of filters used by the SearchBar component.
* If the 'filters' param is not present, a default is returned.
*/
export const searchBarFilters: (state: AlertListState) => Filter[] = createSelector(
uiQueryParams,
({ filters }) => {
if (filters !== undefined) {
return (decode(filters) as unknown) as Filter[];
} else {
return [];
}
}
);
/**
* Returns the indexPatterns used by the SearchBar component
*/
export const searchBarIndexPatterns = (state: AlertListState) => state.searchBar.patterns;
/**
* query params to use when requesting alert data.
*/
export const apiQueryParams: (
state: AlertListState
) => Immutable<AlertingIndexGetQueryInput> = createSelector(
uiQueryParams,
encodedSearchBarDateRange,
({ page_size, page_index, query, filters }, encodedDateRange) => ({
page_size,
page_index,
query,
// Always send a default date range param to the API
// even if there is no date_range param in the url
date_range: encodedDateRange,
filters,
})
);
/**
* True if the user has selected an alert to see details about.
* Populated via the browsers query params.
*/
export const hasSelectedAlert: (state: AlertListState) => boolean = createSelector(
uiQueryParams,
({ selected_alert: selectedAlert }) => selectedAlert !== undefined
);

View file

@ -1,40 +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 { HostListPagination, ServerApiError } from '../../types';
import { HostResultList, HostMetadata } from '../../../../../common/types';
interface ServerReturnedHostList {
type: 'serverReturnedHostList';
payload: HostResultList;
}
interface ServerReturnedHostDetails {
type: 'serverReturnedHostDetails';
payload: HostMetadata;
}
interface ServerFailedToReturnHostDetails {
type: 'serverFailedToReturnHostDetails';
payload: ServerApiError;
}
interface UserPaginatedHostList {
type: 'userPaginatedHostList';
payload: HostListPagination;
}
// Why is FakeActionWithNoPayload here, see: https://github.com/elastic/endpoint-app-team/issues/273
interface FakeActionWithNoPayload {
type: 'fakeActionWithNoPayLoad';
}
export type HostAction =
| ServerReturnedHostList
| ServerReturnedHostDetails
| ServerFailedToReturnHostDetails
| UserPaginatedHostList
| FakeActionWithNoPayload;

View file

@ -1,73 +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 { createStore, Dispatch, Store } from 'redux';
import { HostAction, hostListReducer } from './index';
import { HostListState } from '../../types';
import { listData } from './selectors';
import { mockHostResultList } from './mock_host_result_list';
describe('HostList store concerns', () => {
let store: Store<HostListState>;
let dispatch: Dispatch<HostAction>;
const createTestStore = () => {
store = createStore(hostListReducer);
dispatch = store.dispatch;
};
const loadDataToStore = () => {
dispatch({
type: 'serverReturnedHostList',
payload: mockHostResultList({ request_page_size: 1, request_page_index: 1, total: 10 }),
});
};
describe('# Reducers', () => {
beforeEach(() => {
createTestStore();
});
test('it creates default state', () => {
expect(store.getState()).toEqual({
hosts: [],
pageSize: 10,
pageIndex: 0,
total: 0,
loading: false,
});
});
test('it handles `serverReturnedHostList', () => {
const payload = mockHostResultList({
request_page_size: 1,
request_page_index: 1,
total: 10,
});
dispatch({
type: 'serverReturnedHostList',
payload,
});
const currentState = store.getState();
expect(currentState.hosts).toEqual(payload.hosts);
expect(currentState.pageSize).toEqual(payload.request_page_size);
expect(currentState.pageIndex).toEqual(payload.request_page_index);
expect(currentState.total).toEqual(payload.total);
});
});
describe('# Selectors', () => {
beforeEach(() => {
createTestStore();
loadDataToStore();
});
test('it selects `hostListData`', () => {
const currentState = store.getState();
expect(listData(currentState)).toEqual(currentState.hosts);
});
});
});

View file

@ -1,9 +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.
*/
export { hostListReducer } from './reducer';
export { HostAction } from './action';
export { hostMiddlewareFactory } from './middleware';

View file

@ -1,63 +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 { CoreStart, HttpSetup } from 'kibana/public';
import { applyMiddleware, createStore, Dispatch, Store } from 'redux';
import { coreMock } from '../../../../../../../../src/core/public/mocks';
import { History, createBrowserHistory } from 'history';
import { hostListReducer, hostMiddlewareFactory } from './index';
import { HostResultList } from '../../../../../common/types';
import { HostListState } from '../../types';
import { AppAction } from '../action';
import { listData } from './selectors';
import { DepsStartMock, depsStartMock } from '../../mocks';
import { mockHostResultList } from './mock_host_result_list';
describe('host list middleware', () => {
const sleep = (ms = 100) => new Promise(wakeup => setTimeout(wakeup, ms));
let fakeCoreStart: jest.Mocked<CoreStart>;
let depsStart: DepsStartMock;
let fakeHttpServices: jest.Mocked<HttpSetup>;
let store: Store<HostListState>;
let getState: typeof store['getState'];
let dispatch: Dispatch<AppAction>;
let history: History<never>;
const getEndpointListApiResponse = (): HostResultList => {
return mockHostResultList({ request_page_size: 1, request_page_index: 1, total: 10 });
};
beforeEach(() => {
fakeCoreStart = coreMock.createStart({ basePath: '/mock' });
depsStart = depsStartMock();
fakeHttpServices = fakeCoreStart.http as jest.Mocked<HttpSetup>;
store = createStore(
hostListReducer,
applyMiddleware(hostMiddlewareFactory(fakeCoreStart, depsStart))
);
getState = store.getState;
dispatch = store.dispatch;
history = createBrowserHistory();
});
test('handles `userChangedUrl`', async () => {
const apiResponse = getEndpointListApiResponse();
fakeHttpServices.post.mockResolvedValue(apiResponse);
expect(fakeHttpServices.post).not.toHaveBeenCalled();
dispatch({
type: 'userChangedUrl',
payload: {
...history.location,
pathname: '/hosts',
},
});
await sleep();
expect(fakeHttpServices.post).toHaveBeenCalledWith('/api/endpoint/metadata', {
body: JSON.stringify({
paging_properties: [{ page_index: 0 }, { page_size: 10 }],
}),
});
expect(listData(getState())).toEqual(apiResponse.hosts);
});
});

View file

@ -1,51 +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 { MiddlewareFactory } from '../../types';
import { pageIndex, pageSize, isOnHostPage, hasSelectedHost, uiQueryParams } from './selectors';
import { HostListState } from '../../types';
import { AppAction } from '../action';
export const hostMiddlewareFactory: MiddlewareFactory<HostListState> = coreStart => {
return ({ getState, dispatch }) => next => async (action: AppAction) => {
next(action);
const state = getState();
if (
(action.type === 'userChangedUrl' &&
isOnHostPage(state) &&
hasSelectedHost(state) !== true) ||
action.type === 'userPaginatedHostList'
) {
const hostPageIndex = pageIndex(state);
const hostPageSize = pageSize(state);
const response = await coreStart.http.post('/api/endpoint/metadata', {
body: JSON.stringify({
paging_properties: [{ page_index: hostPageIndex }, { page_size: hostPageSize }],
}),
});
response.request_page_index = hostPageIndex;
dispatch({
type: 'serverReturnedHostList',
payload: response,
});
}
if (action.type === 'userChangedUrl' && hasSelectedHost(state) !== false) {
const { selected_host: selectedHost } = uiQueryParams(state);
try {
const response = await coreStart.http.get(`/api/endpoint/metadata/${selectedHost}`);
dispatch({
type: 'serverReturnedHostDetails',
payload: response,
});
} catch (error) {
dispatch({
type: 'serverFailedToReturnHostDetails',
payload: error,
});
}
}
};
};

View file

@ -1,39 +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 { HostResultList } from '../../../../../common/types';
import { EndpointDocGenerator } from '../../../../../common/generate_data';
export const mockHostResultList: (options?: {
total?: number;
request_page_size?: number;
request_page_index?: number;
}) => HostResultList = (options = {}) => {
const {
total = 1,
request_page_size: requestPageSize = 10,
request_page_index: requestPageIndex = 0,
} = options;
// Skip any that are before the page we're on
const numberToSkip = requestPageSize * requestPageIndex;
// total - numberToSkip is the count of non-skipped ones, but return no more than a pageSize, and no less than 0
const actualCountToReturn = Math.max(Math.min(total - numberToSkip, requestPageSize), 0);
const hosts = [];
for (let index = 0; index < actualCountToReturn; index++) {
const generator = new EndpointDocGenerator('seed');
hosts.push(generator.generateHostMetadata());
}
const mock: HostResultList = {
hosts,
total,
request_page_size: requestPageSize,
request_page_index: requestPageIndex,
};
return mock;
};

View file

@ -1,68 +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 { Reducer } from 'redux';
import { HostListState } from '../../types';
import { AppAction } from '../action';
const initialState = (): HostListState => {
return {
hosts: [],
pageSize: 10,
pageIndex: 0,
total: 0,
loading: false,
detailsError: undefined,
details: undefined,
location: undefined,
};
};
export const hostListReducer: Reducer<HostListState, AppAction> = (
state = initialState(),
action
) => {
if (action.type === 'serverReturnedHostList') {
const {
hosts,
total,
request_page_size: pageSize,
request_page_index: pageIndex,
} = action.payload;
return {
...state,
hosts,
total,
pageSize,
pageIndex,
loading: false,
};
} else if (action.type === 'serverReturnedHostDetails') {
return {
...state,
details: action.payload,
};
} else if (action.type === 'serverFailedToReturnHostDetails') {
return {
...state,
detailsError: action.payload,
};
} else if (action.type === 'userPaginatedHostList') {
return {
...state,
...action.payload,
loading: true,
};
} else if (action.type === 'userChangedUrl') {
return {
...state,
location: action.payload,
detailsError: undefined,
};
}
return state;
};

View file

@ -1,60 +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 querystring from 'querystring';
import { createSelector } from 'reselect';
import { Immutable } from '../../../../../common/types';
import { HostListState, HostIndexUIQueryParams } from '../../types';
export const listData = (state: HostListState) => state.hosts;
export const pageIndex = (state: HostListState) => state.pageIndex;
export const pageSize = (state: HostListState) => state.pageSize;
export const totalHits = (state: HostListState) => state.total;
export const isLoading = (state: HostListState) => state.loading;
export const detailsError = (state: HostListState) => state.detailsError;
export const detailsData = (state: HostListState) => {
return state.details;
};
export const isOnHostPage = (state: HostListState) =>
state.location ? state.location.pathname === '/hosts' : false;
export const uiQueryParams: (
state: HostListState
) => Immutable<HostIndexUIQueryParams> = createSelector(
(state: HostListState) => state.location,
(location: HostListState['location']) => {
const data: HostIndexUIQueryParams = {};
if (location) {
// Removes the `?` from the beginning of query string if it exists
const query = querystring.parse(location.search.slice(1));
const keys: Array<keyof HostIndexUIQueryParams> = ['selected_host'];
for (const key of keys) {
const value = query[key];
if (typeof value === 'string') {
data[key] = value;
} else if (Array.isArray(value)) {
data[key] = value[value.length - 1];
}
}
}
return data;
}
);
export const hasSelectedHost: (state: HostListState) => boolean = createSelector(
uiQueryParams,
({ selected_host: selectedHost }) => {
return selectedHost !== undefined;
}
);

View file

@ -1,96 +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 {
createStore,
compose,
applyMiddleware,
Store,
MiddlewareAPI,
Dispatch,
Middleware,
} from 'redux';
import { CoreStart } from 'kibana/public';
import { appReducer } from './reducer';
import { alertMiddlewareFactory } from './alerts/middleware';
import { hostMiddlewareFactory } from './hosts';
import { policyListMiddlewareFactory } from './policy_list';
import { policyDetailsMiddlewareFactory } from './policy_details';
import { GlobalState } from '../types';
import { AppAction } from './action';
import { EndpointPluginStartDependencies } from '../../../plugin';
const composeWithReduxDevTools = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
? (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: 'EndpointApp' })
: compose;
export type Selector<S, R> = (state: S) => R;
/**
* Wrap Redux Middleware and adjust 'getState()' to return the namespace from 'GlobalState that applies to the given Middleware concern.
*
* @param selector
* @param middleware
*/
export const substateMiddlewareFactory = <Substate>(
selector: Selector<GlobalState, Substate>,
middleware: Middleware<{}, Substate, Dispatch<AppAction>>
): Middleware<{}, GlobalState, Dispatch<AppAction>> => {
return api => {
const substateAPI: MiddlewareAPI<Dispatch<AppAction>, Substate> = {
...api,
getState() {
return selector(api.getState());
},
};
return middleware(substateAPI);
};
};
/**
* @param middlewareDeps Optionally create the store without any middleware. This is useful for testing the store w/o side effects.
*/
export const appStoreFactory: (middlewareDeps?: {
/**
* Allow middleware to communicate with Kibana core.
*/
coreStart: CoreStart;
/**
* Give middleware access to plugin start dependencies.
*/
depsStart: EndpointPluginStartDependencies;
}) => Store = middlewareDeps => {
let middleware;
if (middlewareDeps) {
const { coreStart, depsStart } = middlewareDeps;
middleware = composeWithReduxDevTools(
applyMiddleware(
substateMiddlewareFactory(
globalState => globalState.hostList,
hostMiddlewareFactory(coreStart, depsStart)
),
substateMiddlewareFactory(
globalState => globalState.policyList,
policyListMiddlewareFactory(coreStart, depsStart)
),
substateMiddlewareFactory(
globalState => globalState.policyDetails,
policyDetailsMiddlewareFactory(coreStart, depsStart)
),
substateMiddlewareFactory(
globalState => globalState.alertList,
alertMiddlewareFactory(coreStart, depsStart)
)
)
);
} else {
// Create the store without any middleware. This is useful for testing the store w/o side effects.
middleware = undefined;
}
const store = createStore(appReducer, middleware);
return store;
};

View file

@ -1,16 +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 { PolicyData } from '../../types';
interface ServerReturnedPolicyDetailsData {
type: 'serverReturnedPolicyDetailsData';
payload: {
policyItem: PolicyData | undefined;
};
}
export type PolicyDetailsAction = ServerReturnedPolicyDetailsData;

View file

@ -1,9 +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.
*/
export { policyDetailsMiddlewareFactory } from './middleware';
export { PolicyDetailsAction } from './action';
export { policyDetailsReducer } from './reducer';

View file

@ -1,31 +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 { MiddlewareFactory, PolicyDetailsState } from '../../types';
import { selectPolicyIdFromParams, isOnPolicyDetailsPage } from './selectors';
import { sendGetDatasource } from '../../services/ingest';
export const policyDetailsMiddlewareFactory: MiddlewareFactory<PolicyDetailsState> = coreStart => {
const http = coreStart.http;
return ({ getState, dispatch }) => next => async action => {
next(action);
const state = getState();
if (action.type === 'userChangedUrl' && isOnPolicyDetailsPage(state)) {
const id = selectPolicyIdFromParams(state);
const { item: policyItem } = await sendGetDatasource(http, id);
dispatch({
type: 'serverReturnedPolicyDetailsData',
payload: {
policyItem,
},
});
}
};
};

View file

@ -1,38 +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 { Reducer } from 'redux';
import { PolicyDetailsState } from '../../types';
import { AppAction } from '../action';
const initialPolicyDetailsState = (): PolicyDetailsState => {
return {
policyItem: undefined,
isLoading: false,
};
};
export const policyDetailsReducer: Reducer<PolicyDetailsState, AppAction> = (
state = initialPolicyDetailsState(),
action
) => {
if (action.type === 'serverReturnedPolicyDetailsData') {
return {
...state,
...action.payload,
isLoading: false,
};
}
if (action.type === 'userChangedUrl') {
return {
...state,
location: action.payload,
};
}
return state;
};

View file

@ -1,29 +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 { createSelector } from 'reselect';
import { PolicyDetailsState } from '../../types';
export const selectPolicyDetails = (state: PolicyDetailsState) => state.policyItem;
export const isOnPolicyDetailsPage = (state: PolicyDetailsState) => {
if (state.location) {
const pathnameParts = state.location.pathname.split('/');
return pathnameParts[1] === 'policy' && pathnameParts[2];
} else {
return false;
}
};
export const selectPolicyIdFromParams: (state: PolicyDetailsState) => string = createSelector(
(state: PolicyDetailsState) => state.location,
(location: PolicyDetailsState['location']) => {
if (location) {
return location.pathname.split('/')[2];
}
return '';
}
);

View file

@ -1,35 +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 { PolicyData, ServerApiError } from '../../types';
interface ServerReturnedPolicyListData {
type: 'serverReturnedPolicyListData';
payload: {
policyItems: PolicyData[];
total: number;
pageSize: number;
pageIndex: number;
};
}
interface ServerFailedToReturnPolicyListData {
type: 'serverFailedToReturnPolicyListData';
payload: ServerApiError;
}
interface UserPaginatedPolicyListTable {
type: 'userPaginatedPolicyListTable';
payload: {
pageSize: number;
pageIndex: number;
};
}
export type PolicyListAction =
| ServerReturnedPolicyListData
| UserPaginatedPolicyListTable
| ServerFailedToReturnPolicyListData;

View file

@ -1,79 +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 { PolicyListState } from '../../types';
import { applyMiddleware, createStore, Dispatch, Store } from 'redux';
import { AppAction } from '../action';
import { policyListReducer } from './reducer';
import { policyListMiddlewareFactory } from './middleware';
import { coreMock } from '../../../../../../../../src/core/public/mocks';
import { CoreStart } from 'kibana/public';
import { selectIsLoading } from './selectors';
import { DepsStartMock, depsStartMock } from '../../mocks';
describe('policy list store concerns', () => {
const sleep = () => new Promise(resolve => setTimeout(resolve, 1000));
let fakeCoreStart: jest.Mocked<CoreStart>;
let depsStart: DepsStartMock;
let store: Store<PolicyListState>;
let getState: typeof store['getState'];
let dispatch: Dispatch<AppAction>;
beforeEach(() => {
fakeCoreStart = coreMock.createStart({ basePath: '/mock' });
depsStart = depsStartMock();
store = createStore(
policyListReducer,
applyMiddleware(policyListMiddlewareFactory(fakeCoreStart, depsStart))
);
getState = store.getState;
dispatch = store.dispatch;
});
// https://github.com/elastic/kibana/issues/58972
test.skip('it sets `isLoading` when `userNavigatedToPage`', async () => {
expect(selectIsLoading(getState())).toBe(false);
dispatch({ type: 'userNavigatedToPage', payload: 'policyListPage' });
expect(selectIsLoading(getState())).toBe(true);
await sleep();
expect(selectIsLoading(getState())).toBe(false);
});
// https://github.com/elastic/kibana/issues/58896
test.skip('it sets `isLoading` when `userPaginatedPolicyListTable`', async () => {
expect(selectIsLoading(getState())).toBe(false);
dispatch({
type: 'userPaginatedPolicyListTable',
payload: {
pageSize: 10,
pageIndex: 1,
},
});
expect(selectIsLoading(getState())).toBe(true);
await sleep();
expect(selectIsLoading(getState())).toBe(false);
});
test('it resets state on `userNavigatedFromPage` action', async () => {
dispatch({
type: 'serverReturnedPolicyListData',
payload: {
policyItems: [],
pageIndex: 20,
pageSize: 50,
total: 200,
},
});
dispatch({ type: 'userNavigatedFromPage', payload: 'policyListPage' });
expect(getState()).toEqual({
policyItems: [],
isLoading: false,
pageIndex: 0,
pageSize: 10,
total: 0,
});
});
});

View file

@ -1,9 +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.
*/
export { policyListReducer } from './reducer';
export { PolicyListAction } from './action';
export { policyListMiddlewareFactory } from './middleware';

View file

@ -1,62 +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 { MiddlewareFactory, PolicyListState } from '../../types';
import { GetDatasourcesResponse, sendGetEndpointSpecificDatasources } from '../../services/ingest';
export const policyListMiddlewareFactory: MiddlewareFactory<PolicyListState> = coreStart => {
const http = coreStart.http;
return ({ getState, dispatch }) => next => async action => {
next(action);
if (
(action.type === 'userNavigatedToPage' && action.payload === 'policyListPage') ||
action.type === 'userPaginatedPolicyListTable'
) {
const state = getState();
let pageSize: number;
let pageIndex: number;
if (action.type === 'userPaginatedPolicyListTable') {
pageSize = action.payload.pageSize;
pageIndex = action.payload.pageIndex;
} else {
pageSize = state.pageSize;
pageIndex = state.pageIndex;
}
let response: GetDatasourcesResponse;
try {
response = await sendGetEndpointSpecificDatasources(http, {
query: {
perPage: pageSize,
page: pageIndex + 1,
},
});
} catch (err) {
dispatch({
type: 'serverFailedToReturnPolicyListData',
payload: err.body ?? err,
});
return;
}
const { items: policyItems, total } = response;
dispatch({
type: 'serverReturnedPolicyListData',
payload: {
policyItems,
pageIndex,
pageSize,
total,
},
});
}
};
};

View file

@ -1,58 +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 { Reducer } from 'redux';
import { PolicyListState } from '../../types';
import { AppAction } from '../action';
const initialPolicyListState = (): PolicyListState => {
return {
policyItems: [],
isLoading: false,
apiError: undefined,
pageIndex: 0,
pageSize: 10,
total: 0,
};
};
export const policyListReducer: Reducer<PolicyListState, AppAction> = (
state = initialPolicyListState(),
action
) => {
if (action.type === 'serverReturnedPolicyListData') {
return {
...state,
...action.payload,
isLoading: false,
};
}
if (action.type === 'serverFailedToReturnPolicyListData') {
return {
...state,
apiError: action.payload,
isLoading: false,
};
}
if (
action.type === 'userPaginatedPolicyListTable' ||
(action.type === 'userNavigatedToPage' && action.payload === 'policyListPage')
) {
return {
...state,
apiError: undefined,
isLoading: true,
};
}
if (action.type === 'userNavigatedFromPage' && action.payload === 'policyListPage') {
return initialPolicyListState();
}
return state;
};

View file

@ -1,19 +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 { PolicyListState } from '../../types';
export const selectPolicyItems = (state: PolicyListState) => state.policyItems;
export const selectPageIndex = (state: PolicyListState) => state.pageIndex;
export const selectPageSize = (state: PolicyListState) => state.pageSize;
export const selectTotal = (state: PolicyListState) => state.total;
export const selectIsLoading = (state: PolicyListState) => state.isLoading;
export const selectApiError = (state: PolicyListState) => state.apiError;

View file

@ -1,19 +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 { combineReducers, Reducer } from 'redux';
import { hostListReducer } from './hosts';
import { AppAction } from './action';
import { alertListReducer } from './alerts';
import { GlobalState } from '../types';
import { policyListReducer } from './policy_list';
import { policyDetailsReducer } from './policy_details';
export const appReducer: Reducer<GlobalState, AppAction> = combineReducers({
hostList: hostListReducer,
alertList: alertListReducer,
policyList: policyListReducer,
policyDetails: policyDetailsReducer,
});

View file

@ -1,25 +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 { PageId, Immutable } from '../../../../../common/types';
import { EndpointAppLocation } from '../alerts';
interface UserNavigatedToPage {
readonly type: 'userNavigatedToPage';
readonly payload: PageId;
}
interface UserNavigatedFromPage {
readonly type: 'userNavigatedFromPage';
readonly payload: PageId;
}
interface UserChangedUrl {
readonly type: 'userChangedUrl';
readonly payload: Immutable<EndpointAppLocation>;
}
export type RoutingAction = UserNavigatedToPage | UserNavigatedFromPage | UserChangedUrl;

View file

@ -1,7 +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.
*/
export { RoutingAction } from './action';

View file

@ -1,164 +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 { Dispatch, MiddlewareAPI } from 'redux';
import { IIndexPattern } from 'src/plugins/data/public';
import {
HostMetadata,
AlertData,
AlertResultList,
Immutable,
ImmutableArray,
} from '../../../common/types';
import { EndpointPluginStartDependencies } from '../../plugin';
import { AppAction } from './store/action';
import { CoreStart } from '../../../../../../src/core/public';
import { Datasource } from '../../../../ingest_manager/common/types/models';
export { AppAction };
export type MiddlewareFactory<S = GlobalState> = (
coreStart: CoreStart,
depsStart: EndpointPluginStartDependencies
) => (
api: MiddlewareAPI<Dispatch<AppAction>, S>
) => (next: Dispatch<AppAction>) => (action: AppAction) => unknown;
export interface HostListState {
hosts: HostMetadata[];
pageSize: number;
pageIndex: number;
total: number;
loading: boolean;
detailsError?: ServerApiError;
details?: Immutable<HostMetadata>;
location?: Immutable<EndpointAppLocation>;
}
export interface HostListPagination {
pageIndex: number;
pageSize: number;
}
export interface HostIndexUIQueryParams {
selected_host?: string;
}
export interface ServerApiError {
statusCode: number;
error: string;
message: string;
}
/**
* An Endpoint Policy.
*/
export type PolicyData = Datasource;
/**
* Policy list store state
*/
export interface PolicyListState {
/** Array of policy items */
policyItems: PolicyData[];
/** API error if loading data failed */
apiError?: ServerApiError;
/** total number of policies */
total: number;
/** Number of policies per page */
pageSize: number;
/** page number (zero based) */
pageIndex: number;
/** data is being retrieved from server */
isLoading: boolean;
}
/**
* Policy list store state
*/
export interface PolicyDetailsState {
/** A single policy item */
policyItem: PolicyData | undefined;
/** data is being retrieved from server */
isLoading: boolean;
/** current location of the application */
location?: Immutable<EndpointAppLocation>;
}
export interface GlobalState {
readonly hostList: HostListState;
readonly alertList: AlertListState;
readonly policyList: PolicyListState;
readonly policyDetails: PolicyDetailsState;
}
/**
* A better type for createStructuredSelector. This doesn't support the options object.
*/
export type CreateStructuredSelector = <
SelectorMap extends { [key: string]: (...args: never[]) => unknown }
>(
selectorMap: SelectorMap
) => (
state: SelectorMap[keyof SelectorMap] extends (state: infer State) => unknown ? State : never
) => {
[Key in keyof SelectorMap]: ReturnType<SelectorMap[Key]>;
};
export interface EndpointAppLocation {
pathname: string;
search: string;
hash: string;
key?: string;
}
interface AlertsSearchBarState {
patterns: IIndexPattern[];
}
export type AlertListData = AlertResultList;
export interface AlertListState {
/** Array of alert items. */
readonly alerts: ImmutableArray<AlertData>;
/** The total number of alerts on the page. */
readonly total: number;
/** Number of alerts per page. */
readonly pageSize: number;
/** Page number, starting at 0. */
readonly pageIndex: number;
/** Current location object from React Router history. */
readonly location?: Immutable<EndpointAppLocation>;
/** Specific Alert data to be shown in the details view */
readonly alertDetails?: Immutable<AlertData>;
/** Search bar state including indexPatterns */
readonly searchBar: AlertsSearchBarState;
}
/**
* Gotten by parsing the URL from the browser. Used to calculate the new URL when changing views.
*/
export interface AlertingIndexUIQueryParams {
/**
* How many items to show in list.
*/
page_size?: string;
/**
* Which page to show. If `page_index` is 1, show page 2.
*/
page_index?: string;
/**
* If any value is present, show the alert detail view for the selected alert. Should be an ID for an alert event.
*/
selected_alert?: string;
query?: string;
date_range?: string;
filters?: string;
}

View file

@ -1,81 +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 * as reactTestingLibrary from '@testing-library/react';
import { appStoreFactory } from '../../store';
import { fireEvent } from '@testing-library/react';
import { MemoryHistory } from 'history';
import { AppAction } from '../../types';
import { mockAlertResultList } from '../../store/alerts/mock_alert_result_list';
import { alertPageTestRender } from './test_helpers/render_alert_page';
describe('when the alert details flyout is open', () => {
let render: () => reactTestingLibrary.RenderResult;
let history: MemoryHistory<never>;
let store: ReturnType<typeof appStoreFactory>;
beforeEach(async () => {
// Creates the render elements for the tests to use
({ render, history, store } = alertPageTestRender);
});
describe('when the alerts details flyout is open', () => {
beforeEach(() => {
reactTestingLibrary.act(() => {
history.push({
search: '?selected_alert=1',
});
});
});
describe('when the data loads', () => {
beforeEach(() => {
reactTestingLibrary.act(() => {
const action: AppAction = {
type: 'serverReturnedAlertDetailsData',
payload: mockAlertResultList().alerts[0],
};
store.dispatch(action);
});
});
it('should display take action button', async () => {
await render().findByTestId('alertDetailTakeActionDropdownButton');
});
describe('when the user clicks the take action button on the flyout', () => {
let renderResult: reactTestingLibrary.RenderResult;
beforeEach(async () => {
renderResult = render();
const takeActionButton = await renderResult.findByTestId(
'alertDetailTakeActionDropdownButton'
);
if (takeActionButton) {
fireEvent.click(takeActionButton);
}
});
it('should display the correct fields in the dropdown', async () => {
await renderResult.findByTestId('alertDetailTakeActionCloseAlertButton');
await renderResult.findByTestId('alertDetailTakeActionWhitelistButton');
});
});
describe('when the user navigates to the overview tab', () => {
let renderResult: reactTestingLibrary.RenderResult;
beforeEach(async () => {
renderResult = render();
const overviewTab = await renderResult.findByTestId('overviewMetadata');
if (overviewTab) {
fireEvent.click(overviewTab);
}
});
it('should render all accordion panels', async () => {
await renderResult.findAllByTestId('alertDetailsAlertAccordion');
await renderResult.findAllByTestId('alertDetailsHostAccordion');
await renderResult.findAllByTestId('alertDetailsFileAccordion');
await renderResult.findAllByTestId('alertDetailsHashAccordion');
await renderResult.findAllByTestId('alertDetailsSourceProcessAccordion');
await renderResult.findAllByTestId('alertDetailsSourceProcessTokenAccordion');
});
});
});
});
});

View file

@ -1,7 +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.
*/
export { AlertDetailsOverview } from './overview';

View file

@ -1,81 +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 React, { memo, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiAccordion, EuiDescriptionList } from '@elastic/eui';
import { Immutable, AlertData } from '../../../../../../../common/types';
import { FormattedDate } from '../../formatted_date';
export const FileAccordion = memo(({ alertData }: { alertData: Immutable<AlertData> }) => {
const columns = useMemo(() => {
return [
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.fileName', {
defaultMessage: 'File Name',
}),
description: alertData.file.name,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.filePath', {
defaultMessage: 'File Path',
}),
description: alertData.file.path,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.fileSize', {
defaultMessage: 'File Size',
}),
description: alertData.file.size,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.fileCreated', {
defaultMessage: 'File Created',
}),
description: <FormattedDate timestamp={alertData.file.created} />,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.fileModified', {
defaultMessage: 'File Modified',
}),
description: <FormattedDate timestamp={alertData.file.mtime} />,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.fileAccessed', {
defaultMessage: 'File Accessed',
}),
description: <FormattedDate timestamp={alertData.file.accessed} />,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.signer', {
defaultMessage: 'Signer',
}),
description: alertData.file.code_signature.subject_name,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.owner', {
defaultMessage: 'Owner',
}),
description: alertData.file.owner,
},
];
}, [alertData]);
return (
<EuiAccordion
id="alertDetailsFileAccordion"
buttonContent={i18n.translate(
'xpack.endpoint.application.endpoint.alertDetails.accordionTitles.file',
{
defaultMessage: 'File',
}
)}
paddingSize="l"
data-test-subj="alertDetailsFileAccordion"
>
<EuiDescriptionList type="column" listItems={columns} />
</EuiAccordion>
);
});

View file

@ -1,69 +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 React, { memo, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiAccordion, EuiDescriptionList } from '@elastic/eui';
import { Immutable, AlertData } from '../../../../../../../common/types';
import { FormattedDate } from '../../formatted_date';
export const GeneralAccordion = memo(({ alertData }: { alertData: Immutable<AlertData> }) => {
const columns = useMemo(() => {
return [
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.alertType', {
defaultMessage: 'Alert Type',
}),
description: alertData.event.category,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.eventType', {
defaultMessage: 'Event Type',
}),
description: alertData.event.kind,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.status', {
defaultMessage: 'Status',
}),
description: 'TODO',
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.dateCreated', {
defaultMessage: 'Date Created',
}),
description: <FormattedDate timestamp={alertData['@timestamp']} />,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.malwareScore', {
defaultMessage: 'MalwareScore',
}),
description: alertData.file.malware_classifier.score,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.fileName', {
defaultMessage: 'File Name',
}),
description: alertData.file.name,
},
];
}, [alertData]);
return (
<EuiAccordion
id="alertDetailsAlertAccordion"
buttonContent={i18n.translate(
'xpack.endpoint.application.endpoint.alertDetails.accordionTitles.alert',
{
defaultMessage: 'Alert',
}
)}
paddingSize="l"
initialIsOpen={true}
data-test-subj="alertDetailsAlertAccordion"
>
<EuiDescriptionList type="column" listItems={columns} />
</EuiAccordion>
);
});

View file

@ -1,50 +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 React, { memo, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiAccordion, EuiDescriptionList } from '@elastic/eui';
import { Immutable, AlertData } from '../../../../../../../common/types';
export const HashAccordion = memo(({ alertData }: { alertData: Immutable<AlertData> }) => {
const columns = useMemo(() => {
return [
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.md5', {
defaultMessage: 'MD5',
}),
description: alertData.file.hash.md5,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.sha1', {
defaultMessage: 'SHA1',
}),
description: alertData.file.hash.sha1,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.sha256', {
defaultMessage: 'SHA256',
}),
description: alertData.file.hash.sha256,
},
];
}, [alertData]);
return (
<EuiAccordion
id="alertDetailsHashAccordion"
buttonContent={i18n.translate(
'xpack.endpoint.application.endpoint.alertDetails.accordionTitles.hash',
{
defaultMessage: 'Hash',
}
)}
paddingSize="l"
data-test-subj="alertDetailsHashAccordion"
>
<EuiDescriptionList type="column" listItems={columns} />
</EuiAccordion>
);
});

View file

@ -1,56 +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 React, { memo, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiAccordion, EuiDescriptionList } from '@elastic/eui';
import { Immutable, AlertData } from '../../../../../../../common/types';
export const HostAccordion = memo(({ alertData }: { alertData: Immutable<AlertData> }) => {
const columns = useMemo(() => {
return [
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.hostName', {
defaultMessage: 'Host Name',
}),
description: alertData.host.hostname,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.hostIP', {
defaultMessage: 'Host IP',
}),
description: alertData.host.ip.join(', '),
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.status', {
defaultMessage: 'Status',
}),
description: 'TODO',
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.os', {
defaultMessage: 'OS',
}),
description: alertData.host.os.name,
},
];
}, [alertData]);
return (
<EuiAccordion
id="alertDetailsHostAccordion"
buttonContent={i18n.translate(
'xpack.endpoint.application.endpoint.alertDetails.accordionTitles.host',
{
defaultMessage: 'Host',
}
)}
paddingSize="l"
data-test-subj="alertDetailsHostAccordion"
>
<EuiDescriptionList type="column" listItems={columns} />
</EuiAccordion>
);
});

View file

@ -1,12 +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.
*/
export { GeneralAccordion } from './general_accordion';
export { HostAccordion } from './host_accordion';
export { HashAccordion } from './hash_accordion';
export { FileAccordion } from './file_accordion';
export { SourceProcessAccordion } from './source_process_accordion';
export { SourceProcessTokenAccordion } from './source_process_token_accordion';

View file

@ -1,98 +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 React, { memo, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiAccordion, EuiDescriptionList } from '@elastic/eui';
import { Immutable, AlertData } from '../../../../../../../common/types';
export const SourceProcessAccordion = memo(({ alertData }: { alertData: Immutable<AlertData> }) => {
const columns = useMemo(() => {
return [
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.processID', {
defaultMessage: 'Process ID',
}),
description: alertData.process.pid,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.processName', {
defaultMessage: 'Process Name',
}),
description: alertData.process.name,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.processPath', {
defaultMessage: 'Process Path',
}),
description: alertData.process.executable,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.md5', {
defaultMessage: 'MD5',
}),
description: alertData.process.hash.md5,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.sha1', {
defaultMessage: 'SHA1',
}),
description: alertData.process.hash.sha1,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.sha256', {
defaultMessage: 'SHA256',
}),
description: alertData.process.hash.sha256,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.malwareScore', {
defaultMessage: 'MalwareScore',
}),
description: alertData.process.malware_classifier?.score || '-',
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.parentProcessID', {
defaultMessage: 'Parent Process ID',
}),
description: alertData.process.parent?.pid || '-',
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.signer', {
defaultMessage: 'Signer',
}),
description: alertData.process.code_signature.subject_name,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.username', {
defaultMessage: 'Username',
}),
description: alertData.process.token.user,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.domain', {
defaultMessage: 'Domain',
}),
description: alertData.process.token.domain,
},
];
}, [alertData]);
return (
<EuiAccordion
id="alertDetailsSourceProcessAccordion"
buttonContent={i18n.translate(
'xpack.endpoint.application.endpoint.alertDetails.accordionTitles.sourceProcess',
{
defaultMessage: 'Source Process',
}
)}
paddingSize="l"
data-test-subj="alertDetailsSourceProcessAccordion"
>
<EuiDescriptionList type="column" listItems={columns} />
</EuiAccordion>
);
});

View file

@ -1,46 +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 React, { memo, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiAccordion, EuiDescriptionList } from '@elastic/eui';
import { Immutable, AlertData } from '../../../../../../../common/types';
export const SourceProcessTokenAccordion = memo(
({ alertData }: { alertData: Immutable<AlertData> }) => {
const columns = useMemo(() => {
return [
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.sid', {
defaultMessage: 'SID',
}),
description: alertData.process.token.sid,
},
{
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.integrityLevel', {
defaultMessage: 'Integrity Level',
}),
description: alertData.process.token.integrity_level,
},
];
}, [alertData]);
return (
<EuiAccordion
id="alertDetailsSourceProcessTokenAccordion"
buttonContent={i18n.translate(
'xpack.endpoint.application.endpoint.alertDetails.accordionTitles.sourceProcessToken',
{
defaultMessage: 'Source Process Token',
}
)}
paddingSize="l"
data-test-subj="alertDetailsSourceProcessTokenAccordion"
>
<EuiDescriptionList type="column" listItems={columns} />
</EuiAccordion>
);
}
);

View file

@ -1,123 +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 React, { memo, useMemo } from 'react';
import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiSpacer,
EuiTitle,
EuiText,
EuiHealth,
EuiTabbedContent,
EuiTabbedContentTab,
} from '@elastic/eui';
import { useAlertListSelector } from '../../hooks/use_alerts_selector';
import * as selectors from '../../../../store/alerts/selectors';
import { MetadataPanel } from './metadata_panel';
import { FormattedDate } from '../../formatted_date';
import { AlertDetailResolver } from '../../resolver';
import { ResolverEvent } from '../../../../../../../common/types';
import { TakeActionDropdown } from './take_action_dropdown';
export const AlertDetailsOverview = styled(
memo(() => {
const alertDetailsData = useAlertListSelector(selectors.selectedAlertDetailsData);
if (alertDetailsData === undefined) {
return null;
}
const tabs: EuiTabbedContentTab[] = useMemo(() => {
return [
{
id: 'overviewMetadata',
'data-test-subj': 'overviewMetadata',
name: i18n.translate(
'xpack.endpoint.application.endpoint.alertDetails.overview.tabs.overview',
{
defaultMessage: 'Overview',
}
),
content: (
<>
<EuiSpacer />
<MetadataPanel />
</>
),
},
{
id: 'overviewResolver',
'data-test-subj': 'overviewResolverTab',
name: i18n.translate(
'xpack.endpoint.application.endpoint.alertDetails.overview.tabs.resolver',
{
defaultMessage: 'Resolver',
}
),
content: (
<>
<EuiSpacer />
<AlertDetailResolver selectedEvent={(alertDetailsData as unknown) as ResolverEvent} />
</>
),
},
];
}, [alertDetailsData]);
return (
<>
<section className="details-overview-summary">
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.endpoint.application.endpoint.alertDetails.overview.title"
defaultMessage="Detected Malicious File"
/>
</h3>
</EuiTitle>
<EuiSpacer />
<EuiText>
<p>
<FormattedMessage
id="xpack.endpoint.application.endpoint.alertDetails.overview.summary"
defaultMessage="MalwareScore detected the opening of a document on {hostname} on {date}"
values={{
hostname: alertDetailsData.host.hostname,
date: <FormattedDate timestamp={alertDetailsData['@timestamp']} />,
}}
/>
</p>
</EuiText>
<EuiSpacer />
<EuiText>
Endpoint Status:{' '}
<EuiHealth color="success">
{' '}
<FormattedMessage
id="xpack.endpoint.application.endpoint.alertDetails.endpoint.status.online"
defaultMessage="Online"
/>
</EuiHealth>
</EuiText>
<EuiText>
{' '}
<FormattedMessage
id="xpack.endpoint.application.endpoint.alertDetails.alert.status.open"
defaultMessage="Alert Status: Open"
/>
</EuiText>
<EuiSpacer />
<TakeActionDropdown />
<EuiSpacer />
</section>
<EuiTabbedContent tabs={tabs} initialSelectedTab={tabs[0]} />
</>
);
})
)`
height: 100%;
width: 100%;
`;

View file

@ -1,40 +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 React, { memo } from 'react';
import { EuiSpacer } from '@elastic/eui';
import { useAlertListSelector } from '../../hooks/use_alerts_selector';
import * as selectors from '../../../../store/alerts/selectors';
import {
GeneralAccordion,
HostAccordion,
HashAccordion,
FileAccordion,
SourceProcessAccordion,
SourceProcessTokenAccordion,
} from '../metadata';
export const MetadataPanel = memo(() => {
const alertDetailsData = useAlertListSelector(selectors.selectedAlertDetailsData);
if (alertDetailsData === undefined) {
return null;
}
return (
<section className="overview-metadata-panel">
<GeneralAccordion alertData={alertDetailsData} />
<EuiSpacer />
<HostAccordion alertData={alertDetailsData} />
<EuiSpacer />
<HashAccordion alertData={alertDetailsData} />
<EuiSpacer />
<FileAccordion alertData={alertDetailsData} />
<EuiSpacer />
<SourceProcessAccordion alertData={alertDetailsData} />
<EuiSpacer />
<SourceProcessTokenAccordion alertData={alertDetailsData} />
</section>
);
});

View file

@ -1,71 +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 React, { memo, useState, useCallback } from 'react';
import { EuiPopover, EuiFormRow, EuiButton, EuiButtonEmpty } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
const TakeActionButton = memo(({ onClick }: { onClick: () => void }) => (
<EuiButton
iconType="arrowDown"
iconSide="right"
data-test-subj="alertDetailTakeActionDropdownButton"
onClick={onClick}
>
<FormattedMessage
id="xpack.endpoint.application.endpoint.alertDetails.takeAction.title"
defaultMessage="Take Action"
/>
</EuiButton>
));
export const TakeActionDropdown = memo(() => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const onClick = useCallback(() => {
setIsDropdownOpen(!isDropdownOpen);
}, [isDropdownOpen]);
const closePopover = useCallback(() => {
setIsDropdownOpen(false);
}, []);
return (
<EuiPopover
button={<TakeActionButton onClick={onClick} />}
isOpen={isDropdownOpen}
anchorPosition="downRight"
closePopover={closePopover}
data-test-subj="alertListTakeActionDropdownContent"
>
<EuiFormRow>
<EuiButtonEmpty
data-test-subj="alertDetailTakeActionCloseAlertButton"
color="text"
iconType="folderCheck"
>
<FormattedMessage
id="xpack.endpoint.application.endpoint.alertDetails.takeAction.close"
defaultMessage="Close Alert"
/>
</EuiButtonEmpty>
</EuiFormRow>
<EuiFormRow>
<EuiButtonEmpty
data-test-subj="alertDetailTakeActionWhitelistButton"
color="text"
iconType="listAdd"
>
<FormattedMessage
id="xpack.endpoint.application.endpoint.alertDetails.takeAction.whitelist"
defaultMessage="Whitelist..."
/>
</EuiButtonEmpty>
</EuiFormRow>
</EuiPopover>
);
});

View file

@ -1,22 +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 React, { memo } from 'react';
import { FormattedDate as ReactIntlFormattedDate } from '@kbn/i18n/react';
export const FormattedDate = memo(({ timestamp }: { timestamp: number }) => {
const date = new Date(timestamp);
return (
<ReactIntlFormattedDate
value={date}
year="numeric"
month="2-digit"
day="2-digit"
hour="2-digit"
minute="2-digit"
second="2-digit"
/>
);
});

View file

@ -1,12 +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 { useSelector } from 'react-redux';
import { GlobalState, AlertListState } from '../../../types';
export function useAlertListSelector<TSelected>(selector: (state: AlertListState) => TSelected) {
return useSelector((state: GlobalState) => selector(state.alertList));
}

View file

@ -1,205 +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 * as reactTestingLibrary from '@testing-library/react';
import { IIndexPattern } from 'src/plugins/data/public';
import { appStoreFactory } from '../../store';
import { fireEvent, act } from '@testing-library/react';
import { MemoryHistory } from 'history';
import { AppAction } from '../../types';
import { mockAlertResultList } from '../../store/alerts/mock_alert_result_list';
import { DepsStartMock } from '../../mocks';
import { alertPageTestRender } from './test_helpers/render_alert_page';
describe('when on the alerting page', () => {
let render: () => reactTestingLibrary.RenderResult;
let history: MemoryHistory<never>;
let store: ReturnType<typeof appStoreFactory>;
let depsStart: DepsStartMock;
beforeEach(async () => {
// Creates the render elements for the tests to use
({ render, history, store, depsStart } = alertPageTestRender);
});
it('should show a data grid', async () => {
await render().findByTestId('alertListGrid');
});
describe('when there is no selected alert in the url', () => {
it('should not show the flyout', () => {
expect(render().queryByTestId('alertDetailFlyout')).toBeNull();
});
describe('when data loads', () => {
beforeEach(() => {
/**
* Dispatch the `serverReturnedAlertsData` action, which is normally dispatched by the middleware
* after interacting with the server.
*/
reactTestingLibrary.act(() => {
const action: AppAction = {
type: 'serverReturnedAlertsData',
payload: mockAlertResultList({ total: 11 }),
};
store.dispatch(action);
});
});
it('should render the alert summary row in the grid', async () => {
const renderResult = render();
const rows = await renderResult.findAllByRole('row');
/**
* There should be a 'row' which is the header, and
* row which is the alert item.
*/
expect(rows).toHaveLength(11);
});
describe('when the user has clicked the alert type in the grid', () => {
let renderResult: reactTestingLibrary.RenderResult;
beforeEach(async () => {
renderResult = render();
const alertLinks = await renderResult.findAllByTestId('alertTypeCellLink');
/**
* This is the cell with the alert type, it has a link.
*/
fireEvent.click(alertLinks[0]);
});
it('should show the flyout', async () => {
await renderResult.findByTestId('alertDetailFlyout');
});
});
});
});
describe('when there is a selected alert in the url', () => {
beforeEach(() => {
reactTestingLibrary.act(() => {
history.push({
...history.location,
search: '?selected_alert=1',
});
});
});
it('should show the flyout', async () => {
await render().findByTestId('alertDetailFlyout');
});
describe('when the user clicks the close button on the flyout', () => {
let renderResult: reactTestingLibrary.RenderResult;
beforeEach(async () => {
renderResult = render();
/**
* Use our helper function to find the flyout's close button, as it uses a different test ID attribute.
*/
const closeButton = await renderResult.findByTestId('euiFlyoutCloseButton');
if (closeButton) {
fireEvent.click(closeButton);
}
});
it('should no longer show the flyout', () => {
expect(render().queryByTestId('alertDetailFlyout')).toBeNull();
});
});
});
describe('when the url has page_size=1 and a page_index=1', () => {
beforeEach(() => {
reactTestingLibrary.act(() => {
history.push({
...history.location,
search: '?page_size=1&page_index=1',
});
});
// the test interacts with the pagination elements, which require data to be loaded
reactTestingLibrary.act(() => {
const action: AppAction = {
type: 'serverReturnedAlertsData',
payload: mockAlertResultList({
total: 20,
}),
};
store.dispatch(action);
});
});
describe('when the user changes page size to 10', () => {
beforeEach(async () => {
const renderResult = render();
const paginationButton = await renderResult.findByTestId('tablePaginationPopoverButton');
if (paginationButton) {
act(() => {
fireEvent.click(paginationButton);
});
}
const show10RowsButton = await renderResult.findByTestId('tablePagination-10-rows');
if (show10RowsButton) {
act(() => {
fireEvent.click(show10RowsButton);
});
}
});
it('should have a page_index of 0', () => {
expect(history.location.search).toBe('?page_size=10');
});
});
});
describe('when there are filtering params in the url', () => {
let indexPatterns: IIndexPattern[];
beforeEach(() => {
/**
* Dispatch the `serverReturnedSearchBarIndexPatterns` action, which is normally dispatched by the middleware
* when the page loads. The SearchBar will not render if there are no indexPatterns in the state.
*/
indexPatterns = [
{ title: 'endpoint-events-1', fields: [{ name: 'host.hostname', type: 'string' }] },
];
reactTestingLibrary.act(() => {
const action: AppAction = {
type: 'serverReturnedSearchBarIndexPatterns',
payload: indexPatterns,
};
store.dispatch(action);
});
const searchBarQueryParam =
'(language%3Akuery%2Cquery%3A%27host.hostname%20%3A%20"DESKTOP-QBBSCUT"%27)';
const searchBarDateRangeParam = '(from%3Anow-1y%2Cto%3Anow)';
reactTestingLibrary.act(() => {
history.push({
...history.location,
search: `?query=${searchBarQueryParam}&date_range=${searchBarDateRangeParam}`,
});
});
});
it("should render the SearchBar component with the correct 'indexPatterns' prop", async () => {
render();
const callProps = depsStart.data.ui.SearchBar.mock.calls[0][0];
expect(callProps.indexPatterns).toEqual(indexPatterns);
});
it("should render the SearchBar component with the correct 'query' prop", async () => {
render();
const callProps = depsStart.data.ui.SearchBar.mock.calls[0][0];
const expectedProp = { query: 'host.hostname : "DESKTOP-QBBSCUT"', language: 'kuery' };
expect(callProps.query).toEqual(expectedProp);
});
it("should render the SearchBar component with the correct 'dateRangeFrom' prop", async () => {
render();
const callProps = depsStart.data.ui.SearchBar.mock.calls[0][0];
const expectedProp = 'now-1y';
expect(callProps.dateRangeFrom).toEqual(expectedProp);
});
it("should render the SearchBar component with the correct 'dateRangeTo' prop", async () => {
render();
const callProps = depsStart.data.ui.SearchBar.mock.calls[0][0];
const expectedProp = 'now';
expect(callProps.dateRangeTo).toEqual(expectedProp);
});
it('should render the SearchBar component with the correct display props', async () => {
render();
const callProps = depsStart.data.ui.SearchBar.mock.calls[0][0];
expect(callProps.showFilterBar).toBe(true);
expect(callProps.showDatePicker).toBe(true);
expect(callProps.showQueryBar).toBe(true);
expect(callProps.showQueryInput).toBe(true);
expect(callProps.showSaveQuery).toBe(false);
});
});
});

View file

@ -1,262 +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 { memo, useState, useMemo, useCallback } from 'react';
import React from 'react';
import {
EuiDataGrid,
EuiDataGridColumn,
EuiPage,
EuiPageBody,
EuiPageContent,
EuiFlyout,
EuiFlyoutHeader,
EuiFlyoutBody,
EuiTitle,
EuiBadge,
EuiLoadingSpinner,
EuiPageContentHeader,
EuiPageContentHeaderSection,
EuiPageContentBody,
EuiLink,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useHistory } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n/react';
import { urlFromQueryParams } from './url_from_query_params';
import { AlertData } from '../../../../../common/types';
import * as selectors from '../../store/alerts/selectors';
import { useAlertListSelector } from './hooks/use_alerts_selector';
import { AlertDetailsOverview } from './details';
import { FormattedDate } from './formatted_date';
import { AlertIndexSearchBar } from './index_search_bar';
export const AlertIndex = memo(() => {
const history = useHistory();
const columns = useMemo((): EuiDataGridColumn[] => {
return [
{
id: 'alert_type',
display: i18n.translate('xpack.endpoint.application.endpoint.alerts.alertType', {
defaultMessage: 'Alert Type',
}),
},
{
id: 'event_type',
display: i18n.translate('xpack.endpoint.application.endpoint.alerts.eventType', {
defaultMessage: 'Event Type',
}),
},
{
id: 'os',
display: i18n.translate('xpack.endpoint.application.endpoint.alerts.os', {
defaultMessage: 'OS',
}),
},
{
id: 'ip_address',
display: i18n.translate('xpack.endpoint.application.endpoint.alerts.ipAddress', {
defaultMessage: 'IP Address',
}),
},
{
id: 'host_name',
display: i18n.translate('xpack.endpoint.application.endpoint.alerts.hostName', {
defaultMessage: 'Host Name',
}),
},
{
id: 'timestamp',
display: i18n.translate('xpack.endpoint.application.endpoint.alerts.timestamp', {
defaultMessage: 'Timestamp',
}),
},
{
id: 'archived',
display: i18n.translate('xpack.endpoint.application.endpoint.alerts.archived', {
defaultMessage: 'Archived',
}),
},
{
id: 'malware_score',
display: i18n.translate('xpack.endpoint.application.endpoint.alerts.malwareScore', {
defaultMessage: 'Malware Score',
}),
},
];
}, []);
const { pageIndex, pageSize, total } = useAlertListSelector(selectors.alertListPagination);
const alertListData = useAlertListSelector(selectors.alertListData);
const hasSelectedAlert = useAlertListSelector(selectors.hasSelectedAlert);
const queryParams = useAlertListSelector(selectors.uiQueryParams);
const onChangeItemsPerPage = useCallback(
newPageSize => {
const newQueryParms = { ...queryParams };
newQueryParms.page_size = newPageSize;
delete newQueryParms.page_index;
const relativeURL = urlFromQueryParams(newQueryParms);
return history.push(relativeURL);
},
[history, queryParams]
);
const onChangePage = useCallback(
newPageIndex => {
return history.push(
urlFromQueryParams({
...queryParams,
page_index: newPageIndex,
})
);
},
[history, queryParams]
);
const [visibleColumns, setVisibleColumns] = useState(() => columns.map(({ id }) => id));
const handleFlyoutClose = useCallback(() => {
const { selected_alert, ...paramsWithoutSelectedAlert } = queryParams;
history.push(urlFromQueryParams(paramsWithoutSelectedAlert));
}, [history, queryParams]);
const timestampForRows: Map<AlertData, number> = useMemo(() => {
return new Map(
alertListData.map(alertData => {
return [alertData, alertData['@timestamp']];
})
);
}, [alertListData]);
const renderCellValue = useMemo(() => {
return ({ rowIndex, columnId }: { rowIndex: number; columnId: string }) => {
if (rowIndex > total) {
return null;
}
const row = alertListData[rowIndex % pageSize];
if (columnId === 'alert_type') {
return (
<EuiLink
data-test-subj="alertTypeCellLink"
onClick={() =>
history.push(urlFromQueryParams({ ...queryParams, selected_alert: row.id }))
}
>
{i18n.translate(
'xpack.endpoint.application.endpoint.alerts.alertType.maliciousFileDescription',
{
defaultMessage: 'Malicious File',
}
)}
</EuiLink>
);
} else if (columnId === 'event_type') {
return row.event.action;
} else if (columnId === 'os') {
return row.host.os.name;
} else if (columnId === 'ip_address') {
return row.host.ip;
} else if (columnId === 'host_name') {
return row.host.hostname;
} else if (columnId === 'timestamp') {
const timestamp = timestampForRows.get(row)!;
if (timestamp) {
return <FormattedDate timestamp={timestamp} />;
} else {
return (
<EuiBadge color="warning">
{i18n.translate(
'xpack.endpoint.application.endpoint.alerts.alertDate.timestampInvalidLabel',
{
defaultMessage: 'invalid',
}
)}
</EuiBadge>
);
}
} else if (columnId === 'archived') {
return null;
} else if (columnId === 'malware_score') {
return row.file.malware_classifier.score;
}
return null;
};
}, [total, alertListData, pageSize, history, queryParams, timestampForRows]);
const pagination = useMemo(() => {
return {
pageIndex,
pageSize,
pageSizeOptions: [10, 20, 50],
onChangeItemsPerPage,
onChangePage,
};
}, [onChangeItemsPerPage, onChangePage, pageIndex, pageSize]);
const columnVisibility = useMemo(
() => ({
visibleColumns,
setVisibleColumns,
}),
[setVisibleColumns, visibleColumns]
);
const selectedAlertData = useAlertListSelector(selectors.selectedAlertDetailsData);
return (
<>
{hasSelectedAlert && (
<EuiFlyout data-test-subj="alertDetailFlyout" size="l" onClose={handleFlyoutClose}>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
{i18n.translate('xpack.endpoint.application.endpoint.alerts.detailsTitle', {
defaultMessage: 'Alert Details',
})}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{selectedAlertData ? <AlertDetailsOverview /> : <EuiLoadingSpinner size="xl" />}
</EuiFlyoutBody>
</EuiFlyout>
)}
<EuiPage data-test-subj="alertListPage">
<EuiPageBody>
<EuiPageContent>
<EuiPageContentHeader>
<EuiPageContentHeaderSection>
<EuiTitle size="l">
<h1 data-test-subj="alertsViewTitle">
<FormattedMessage
id="xpack.endpoint.alertList.viewTitle"
defaultMessage="Alerts"
/>
</h1>
</EuiTitle>
</EuiPageContentHeaderSection>
</EuiPageContentHeader>
<EuiPageContentBody>
<AlertIndexSearchBar />
<EuiDataGrid
aria-label="Alert List"
rowCount={total}
columns={columns}
columnVisibility={columnVisibility}
renderCellValue={renderCellValue}
pagination={pagination}
data-test-subj="alertListGrid"
/>
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
</>
);
});

View file

@ -1,85 +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 React from 'react';
import { memo, useEffect, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { encode, RisonValue } from 'rison-node';
import { Query, TimeRange } from 'src/plugins/data/public';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { urlFromQueryParams } from './url_from_query_params';
import { useAlertListSelector } from './hooks/use_alerts_selector';
import * as selectors from '../../store/alerts/selectors';
import { EndpointPluginServices } from '../../../../plugin';
export const AlertIndexSearchBar = memo(() => {
const history = useHistory();
const queryParams = useAlertListSelector(selectors.uiQueryParams);
const searchBarIndexPatterns = useAlertListSelector(selectors.searchBarIndexPatterns);
const searchBarQuery = useAlertListSelector(selectors.searchBarQuery);
const searchBarDateRange = useAlertListSelector(selectors.searchBarDateRange);
const searchBarFilters = useAlertListSelector(selectors.searchBarFilters);
const kibanaContext = useKibana<EndpointPluginServices>();
const {
ui: { SearchBar },
query: { filterManager },
} = kibanaContext.services.data;
useEffect(() => {
// Update the the filters in filterManager when the filters url value (searchBarFilters) changes
filterManager.setFilters(searchBarFilters);
const filterSubscription = filterManager.getUpdates$().subscribe({
next: () => {
history.push(
urlFromQueryParams({
...queryParams,
filters: encode((filterManager.getFilters() as unknown) as RisonValue),
})
);
},
});
return () => {
filterSubscription.unsubscribe();
};
}, [filterManager, history, queryParams, searchBarFilters]);
const onQuerySubmit = useCallback(
(params: { dateRange: TimeRange; query?: Query }) => {
history.push(
urlFromQueryParams({
...queryParams,
query: encode((params.query as unknown) as RisonValue),
date_range: encode((params.dateRange as unknown) as RisonValue),
})
);
},
[history, queryParams]
);
return (
<div>
{searchBarIndexPatterns.length > 0 && (
<SearchBar
dataTestSubj="alertsSearchBar"
appName="endpoint"
isLoading={false}
indexPatterns={searchBarIndexPatterns}
query={searchBarQuery}
dateRangeFrom={searchBarDateRange.from}
dateRangeTo={searchBarDateRange.to}
onQuerySubmit={onQuerySubmit}
showFilterBar={true}
showDatePicker={true}
showQueryBar={true}
showQueryInput={true}
showSaveQuery={false}
/>
)}
</div>
);
});

View file

@ -1,37 +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 React from 'react';
import styled from 'styled-components';
import { Provider } from 'react-redux';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { Resolver } from '../../../../embeddables/resolver/view';
import { EndpointPluginServices } from '../../../../plugin';
import { ResolverEvent } from '../../../../../common/types';
import { storeFactory } from '../../../../embeddables/resolver/store';
export const AlertDetailResolver = styled(
React.memo(
({ className, selectedEvent }: { className?: string; selectedEvent?: ResolverEvent }) => {
const context = useKibana<EndpointPluginServices>();
const { store } = storeFactory(context);
return (
<div className={className} data-test-subj="alertResolver">
<Provider store={store}>
<Resolver selectedEvent={selectedEvent} />
</Provider>
</div>
);
}
)
)`
height: 100%;
width: 100%;
display: flex;
flex-grow: 1;
min-height: 500px;
`;

View file

@ -1,59 +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 React from 'react';
import * as reactTestingLibrary from '@testing-library/react';
import { Provider } from 'react-redux';
import { I18nProvider } from '@kbn/i18n/react';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';
import { AlertIndex } from '../index';
import { appStoreFactory } from '../../../store';
import { KibanaContextProvider } from '../../../../../../../../../src/plugins/kibana_react/public';
import { RouteCapture } from '../../route_capture';
import { depsStartMock } from '../../../mocks';
/**
* Create a 'history' instance that is only in-memory and causes no side effects to the testing environment.
*/
const history = createMemoryHistory<never>();
/**
* Create a store, with the middleware disabled. We don't want side effects being created by our code in this test.
*/
const store = appStoreFactory();
const depsStart = depsStartMock();
depsStart.data.ui.SearchBar.mockImplementation(() => <div />);
export const alertPageTestRender = {
store,
history,
depsStart,
/**
* Render the test component, use this after setting up anything in `beforeEach`.
*/
render: () => {
/**
* Provide the store via `Provider`, and i18n APIs via `I18nProvider`.
* Use react-router via `Router`, passing our in-memory `history` instance.
* Use `RouteCapture` to emit url-change actions when the URL is changed.
* Finally, render the `AlertIndex` component which we are testing.
*/
return reactTestingLibrary.render(
<Provider store={store}>
<KibanaContextProvider services={{ data: depsStart.data }}>
<I18nProvider>
<Router history={history}>
<RouteCapture>
<AlertIndex />
</RouteCapture>
</Router>
</I18nProvider>
</KibanaContextProvider>
</Provider>
);
},
};

View file

@ -1,31 +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 querystring from 'querystring';
import { AlertingIndexUIQueryParams, EndpointAppLocation } from '../../types';
/**
* Return a relative URL for `AlertingIndexUIQueryParams`.
* usage:
*
* ```ts
* // Replace this with however you get state, e.g. useSelector in react
* const queryParams = selectors.uiQueryParams(store.getState())
*
* // same as current url, but page_index is now 3
* const relativeURL = urlFromQueryParams({ ...queryParams, page_index: 3 })
*
* // now use relativeURL in the 'href' of a link, the 'to' of a react-router-dom 'Link' or history.push, history.replace
* ```
*/
export function urlFromQueryParams(
queryParams: AlertingIndexUIQueryParams
): Partial<EndpointAppLocation> {
const search = querystring.stringify(queryParams);
return {
search,
};
}

View file

@ -1,24 +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 React from 'react';
import { FormattedDate, FormattedTime, FormattedRelative } from '@kbn/i18n/react';
export const FormattedDateAndTime: React.FC<{ date: Date }> = ({ date }) => {
// If date is greater than or equal to 1h (ago), then show it as a date
// else, show it as relative to "now"
return Date.now() - date.getTime() >= 3.6e6 ? (
<>
<FormattedDate value={date} year="numeric" month="short" day="2-digit" />
{' @'}
<FormattedTime value={date} />
</>
) : (
<>
<FormattedRelative value={date} />
</>
);
};

View file

@ -1,172 +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 React, { useCallback, useMemo, memo, useEffect } from 'react';
import {
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiTitle,
EuiDescriptionList,
EuiLoadingContent,
EuiHorizontalRule,
EuiHealth,
EuiSpacer,
EuiListGroup,
EuiListGroupItem,
} from '@elastic/eui';
import { useHistory } from 'react-router-dom';
import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { HostMetadata } from '../../../../../common/types';
import { useHostListSelector } from './hooks';
import { urlFromQueryParams } from './url_from_query_params';
import { FormattedDateAndTime } from '../formatted_date_time';
import { uiQueryParams, detailsData, detailsError } from './../../store/hosts/selectors';
const HostIds = styled(EuiListGroupItem)`
margin-top: 0;
.euiListGroupItem__text {
padding: 0;
}
`;
const HostDetails = memo(({ details }: { details: HostMetadata }) => {
const detailsResultsUpper = useMemo(() => {
return [
{
title: i18n.translate('xpack.endpoint.host.details.os', {
defaultMessage: 'OS',
}),
description: details.host.os.full,
},
{
title: i18n.translate('xpack.endpoint.host.details.lastSeen', {
defaultMessage: 'Last Seen',
}),
description: <FormattedDateAndTime date={new Date(details['@timestamp'])} />,
},
{
title: i18n.translate('xpack.endpoint.host.details.alerts', {
defaultMessage: 'Alerts',
}),
description: '0',
},
];
}, [details]);
const detailsResultsLower = useMemo(() => {
return [
{
title: i18n.translate('xpack.endpoint.host.details.policy', {
defaultMessage: 'Policy',
}),
description: details.endpoint.policy.id,
},
{
title: i18n.translate('xpack.endpoint.host.details.policyStatus', {
defaultMessage: 'Policy Status',
}),
description: <EuiHealth color="success">active</EuiHealth>,
},
{
title: i18n.translate('xpack.endpoint.host.details.ipAddress', {
defaultMessage: 'IP Address',
}),
description: (
<EuiListGroup flush>
{details.host.ip.map((ip: string, index: number) => (
<HostIds key={index} label={ip} />
))}
</EuiListGroup>
),
},
{
title: i18n.translate('xpack.endpoint.host.details.hostname', {
defaultMessage: 'Hostname',
}),
description: details.host.hostname,
},
{
title: i18n.translate('xpack.endpoint.host.details.sensorVersion', {
defaultMessage: 'Sensor Version',
}),
description: details.agent.version,
},
];
}, [details.agent.version, details.endpoint.policy.id, details.host.hostname, details.host.ip]);
return (
<>
<EuiDescriptionList
type="column"
listItems={detailsResultsUpper}
data-test-subj="hostDetailsUpperList"
/>
<EuiHorizontalRule margin="s" />
<EuiDescriptionList
type="column"
listItems={detailsResultsLower}
data-test-subj="hostDetailsLowerList"
/>
</>
);
});
export const HostDetailsFlyout = () => {
const history = useHistory();
const { notifications } = useKibana();
const queryParams = useHostListSelector(uiQueryParams);
const { selected_host: selectedHost, ...queryParamsWithoutSelectedHost } = queryParams;
const details = useHostListSelector(detailsData);
const error = useHostListSelector(detailsError);
const handleFlyoutClose = useCallback(() => {
history.push(urlFromQueryParams(queryParamsWithoutSelectedHost));
}, [history, queryParamsWithoutSelectedHost]);
useEffect(() => {
if (error !== undefined) {
notifications.toasts.danger({
title: (
<FormattedMessage
id="xpack.endpoint.host.details.errorTitle"
defaultMessage="Could not find host"
/>
),
body: (
<FormattedMessage
id="xpack.endpoint.host.details.errorBody"
defaultMessage="Please exit the flyout and select an available host."
/>
),
toastLifeTimeMs: 10000,
});
}
}, [error, notifications.toasts]);
return (
<EuiFlyout onClose={handleFlyoutClose} data-test-subj="hostDetailsFlyout">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2 data-test-subj="hostDetailsFlyoutTitle">
{details === undefined ? <EuiLoadingContent lines={1} /> : details.host.hostname}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{details === undefined ? (
<>
<EuiLoadingContent lines={3} /> <EuiSpacer size="l" /> <EuiLoadingContent lines={3} />
</>
) : (
<HostDetails details={details} />
)}
</EuiFlyoutBody>
</EuiFlyout>
);
};

View file

@ -1,14 +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 { useSelector } from 'react-redux';
import { GlobalState, HostListState } from '../../types';
export function useHostListSelector<TSelected>(selector: (state: HostListState) => TSelected) {
return useSelector(function(state: GlobalState) {
return selector(state.hostList);
});
}

View file

@ -1,125 +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 React from 'react';
import * as reactTestingLibrary from '@testing-library/react';
import { Provider } from 'react-redux';
import { I18nProvider } from '@kbn/i18n/react';
import { EuiThemeProvider } from '../../../../../../../legacy/common/eui_styled_components';
import { appStoreFactory } from '../../store';
import { RouteCapture } from '../route_capture';
import { createMemoryHistory, MemoryHistory } from 'history';
import { Router } from 'react-router-dom';
import { AppAction } from '../../types';
import { HostList } from './index';
import { mockHostResultList } from '../../store/hosts/mock_host_result_list';
describe('when on the hosts page', () => {
let render: () => reactTestingLibrary.RenderResult;
let history: MemoryHistory<never>;
let store: ReturnType<typeof appStoreFactory>;
let queryByTestSubjId: (
renderResult: reactTestingLibrary.RenderResult,
testSubjId: string
) => Promise<Element | null>;
beforeEach(async () => {
history = createMemoryHistory<never>();
store = appStoreFactory();
render = () => {
return reactTestingLibrary.render(
<Provider store={store}>
<I18nProvider>
<EuiThemeProvider>
<Router history={history}>
<RouteCapture>
<HostList />
</RouteCapture>
</Router>
</EuiThemeProvider>
</I18nProvider>
</Provider>
);
};
queryByTestSubjId = async (renderResult, testSubjId) => {
return await reactTestingLibrary.waitForElement(
() => document.body.querySelector(`[data-test-subj="${testSubjId}"]`),
{
container: renderResult.container,
}
);
};
});
it('should show a table', async () => {
const renderResult = render();
const table = await renderResult.findByTestId('hostListTable');
expect(table).not.toBeNull();
});
describe('when there is no selected host in the url', () => {
it('should not show the flyout', () => {
const renderResult = render();
expect.assertions(1);
return renderResult.findByTestId('hostDetailsFlyout').catch(e => {
expect(e).not.toBeNull();
});
});
describe('when data loads', () => {
beforeEach(() => {
reactTestingLibrary.act(() => {
const action: AppAction = {
type: 'serverReturnedHostList',
payload: mockHostResultList(),
};
store.dispatch(action);
});
});
it('should render the host summary row in the table', async () => {
const renderResult = render();
const rows = await renderResult.findAllByRole('row');
expect(rows).toHaveLength(2);
});
describe('when the user clicks the hostname in the table', () => {
let renderResult: reactTestingLibrary.RenderResult;
beforeEach(async () => {
renderResult = render();
const detailsLink = await queryByTestSubjId(renderResult, 'hostnameCellLink');
if (detailsLink) {
reactTestingLibrary.fireEvent.click(detailsLink);
}
});
it('should show the flyout', () => {
return renderResult.findByTestId('hostDetailsFlyout').then(flyout => {
expect(flyout).not.toBeNull();
});
});
});
});
});
describe('when there is a selected host in the url', () => {
beforeEach(() => {
reactTestingLibrary.act(() => {
history.push({
...history.location,
search: '?selected_host=1',
});
});
});
it('should show the flyout', () => {
const renderResult = render();
return renderResult.findByTestId('hostDetailsFlyout').then(flyout => {
expect(flyout).not.toBeNull();
});
});
});
});

View file

@ -1,209 +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 React, { useMemo, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom';
import {
EuiPage,
EuiPageBody,
EuiPageHeader,
EuiPageContent,
EuiHorizontalRule,
EuiTitle,
EuiBasicTable,
EuiText,
EuiLink,
EuiHealth,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
import { FormattedMessage } from '@kbn/i18n/react';
import { createStructuredSelector } from 'reselect';
import { HostDetailsFlyout } from './details';
import * as selectors from '../../store/hosts/selectors';
import { HostAction } from '../../store/hosts/action';
import { useHostListSelector } from './hooks';
import { CreateStructuredSelector } from '../../types';
import { urlFromQueryParams } from './url_from_query_params';
const selector = (createStructuredSelector as CreateStructuredSelector)(selectors);
export const HostList = () => {
const dispatch = useDispatch<(a: HostAction) => void>();
const history = useHistory();
const {
listData,
pageIndex,
pageSize,
totalHits: totalItemCount,
isLoading,
uiQueryParams: queryParams,
hasSelectedHost,
} = useHostListSelector(selector);
const paginationSetup = useMemo(() => {
return {
pageIndex,
pageSize,
totalItemCount,
pageSizeOptions: [10, 20, 50],
hidePerPageOptions: false,
};
}, [pageIndex, pageSize, totalItemCount]);
const onTableChange = useCallback(
({ page }: { page: { index: number; size: number } }) => {
const { index, size } = page;
dispatch({
type: 'userPaginatedHostList',
payload: { pageIndex: index, pageSize: size },
});
},
[dispatch]
);
const columns = useMemo(() => {
return [
{
field: '',
name: i18n.translate('xpack.endpoint.host.list.hostname', {
defaultMessage: 'Hostname',
}),
render: ({ host: { hostname, id } }: { host: { hostname: string; id: string } }) => {
return (
// eslint-disable-next-line @elastic/eui/href-or-on-click
<EuiLink
data-test-subj="hostnameCellLink"
href={'?' + urlFromQueryParams({ ...queryParams, selected_host: id }).search}
onClick={(ev: React.MouseEvent) => {
ev.preventDefault();
history.push(urlFromQueryParams({ ...queryParams, selected_host: id }));
}}
>
{hostname}
</EuiLink>
);
},
},
{
field: '',
name: i18n.translate('xpack.endpoint.host.list.policy', {
defaultMessage: 'Policy',
}),
render: () => {
return 'Policy Name';
},
},
{
field: '',
name: i18n.translate('xpack.endpoint.host.list.policyStatus', {
defaultMessage: 'Policy Status',
}),
render: () => {
return <EuiHealth color="success">Policy Status</EuiHealth>;
},
},
{
field: '',
name: i18n.translate('xpack.endpoint.host.list.alerts', {
defaultMessage: 'Alerts',
}),
dataType: 'number',
render: () => {
return '0';
},
},
{
field: 'host.os.name',
name: i18n.translate('xpack.endpoint.host.list.os', {
defaultMessage: 'Operating System',
}),
},
{
field: 'host.ip',
name: i18n.translate('xpack.endpoint.host.list.ip', {
defaultMessage: 'IP Address',
}),
},
{
field: '',
name: i18n.translate('xpack.endpoint.host.list.sensorVersion', {
defaultMessage: 'Sensor Version',
}),
render: () => {
return 'version';
},
},
{
field: '',
name: i18n.translate('xpack.endpoint.host.list.lastActive', {
defaultMessage: 'Last Active',
}),
dataType: 'date',
render: () => {
return 'xxxx';
},
},
];
}, [queryParams, history]);
return (
<HostPage>
{hasSelectedHost && <HostDetailsFlyout />}
<EuiPage className="hostPage">
<EuiPageBody>
<EuiPageHeader className="hostHeader">
<EuiTitle size="l">
<h1 data-test-subj="hostListTitle">
<FormattedMessage id="xpack.endpoint.host.hosts" defaultMessage="Hosts" />
</h1>
</EuiTitle>
</EuiPageHeader>
<EuiPageContent className="hostPageContent">
<EuiText color="subdued" size="xs">
<FormattedMessage
id="xpack.endpoint.host.list.totalCount"
defaultMessage="Showing: {totalItemCount, plural, one {# Host} other {# Hosts}}"
values={{ totalItemCount }}
/>
</EuiText>
<EuiHorizontalRule margin="xs" />
<EuiBasicTable
data-test-subj="hostListTable"
items={listData}
columns={columns}
loading={isLoading}
pagination={paginationSetup}
onChange={onTableChange}
/>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
</HostPage>
);
};
const HostPage = styled.div`
.hostPage {
padding: 0;
}
.hostHeader {
background-color: ${props => props.theme.eui.euiColorLightestShade};
border-bottom: ${props => props.theme.eui.euiBorderThin};
padding: ${props =>
props.theme.eui.euiSizeXL +
' ' +
0 +
props.theme.eui.euiSizeXL +
' ' +
props.theme.eui.euiSizeL};
margin-bottom: 0;
}
.hostPageContent {
border: none;
}
`;

View file

@ -1,17 +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 querystring from 'querystring';
import { EndpointAppLocation, HostIndexUIQueryParams } from '../../types';
export function urlFromQueryParams(
queryParams: HostIndexUIQueryParams
): Partial<EndpointAppLocation> {
const search = querystring.stringify(queryParams);
return {
search,
};
}

View file

@ -1,8 +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.
*/
export * from './policy_list';
export * from './policy_details';

View file

@ -1,36 +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 React from 'react';
import { EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { usePolicyDetailsSelector } from './policy_hooks';
import { selectPolicyDetails } from '../../store/policy_details/selectors';
export const PolicyDetails = React.memo(() => {
const policyItem = usePolicyDetailsSelector(selectPolicyDetails);
function policyName() {
if (policyItem) {
return <span data-test-subj="policyDetailsName">{policyItem.name}</span>;
} else {
return (
<span data-test-subj="policyDetailsNotFound">
<FormattedMessage
id="xpack.endpoint.policyDetails.notFound"
defaultMessage="Policy Not Found"
/>
</span>
);
}
}
return (
<EuiTitle size="l">
<h1 data-test-subj="policyDetailsViewTitle">{policyName()}</h1>
</EuiTitle>
);
});

View file

@ -1,18 +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 { useSelector } from 'react-redux';
import { GlobalState, PolicyListState, PolicyDetailsState } from '../../types';
export function usePolicyListSelector<TSelected>(selector: (state: PolicyListState) => TSelected) {
return useSelector((state: GlobalState) => selector(state.policyList));
}
export function usePolicyDetailsSelector<TSelected>(
selector: (state: PolicyDetailsState) => TSelected
) {
return useSelector((state: GlobalState) => selector(state.policyDetails));
}

View file

@ -1,206 +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 React, { SyntheticEvent, useCallback, useEffect, useMemo } from 'react';
import {
EuiPage,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageContentHeader,
EuiPageContentHeaderSection,
EuiTitle,
EuiBasicTable,
EuiText,
EuiTableFieldDataColumnType,
EuiLink,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { usePageId } from '../use_page_id';
import {
selectApiError,
selectIsLoading,
selectPageIndex,
selectPageSize,
selectPolicyItems,
selectTotal,
} from '../../store/policy_list/selectors';
import { usePolicyListSelector } from './policy_hooks';
import { PolicyListAction } from '../../store/policy_list';
import { PolicyData } from '../../types';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
interface TableChangeCallbackArguments {
page: { index: number; size: number };
}
const PolicyLink: React.FC<{ name: string; route: string }> = ({ name, route }) => {
const history = useHistory();
return (
<EuiLink
onClick={(event: React.MouseEvent) => {
event.preventDefault();
history.push(route);
}}
>
{name}
</EuiLink>
);
};
const renderPolicyNameLink = (value: string, _item: PolicyData) => {
return <PolicyLink name={value} route={`/policy/${_item.id}`} />;
};
export const PolicyList = React.memo(() => {
usePageId('policyListPage');
const { services, notifications } = useKibana();
const dispatch = useDispatch<(action: PolicyListAction) => void>();
const policyItems = usePolicyListSelector(selectPolicyItems);
const pageIndex = usePolicyListSelector(selectPageIndex);
const pageSize = usePolicyListSelector(selectPageSize);
const totalItemCount = usePolicyListSelector(selectTotal);
const loading = usePolicyListSelector(selectIsLoading);
const apiError = usePolicyListSelector(selectApiError);
useEffect(() => {
if (apiError) {
notifications.toasts.danger({
title: apiError.error,
body: apiError.message,
toastLifeTimeMs: 10000,
});
}
}, [apiError, dispatch, notifications.toasts]);
const paginationSetup = useMemo(() => {
return {
pageIndex,
pageSize,
totalItemCount,
pageSizeOptions: [10, 20, 50],
hidePerPageOptions: false,
};
}, [pageIndex, pageSize, totalItemCount]);
const handleTableChange = useCallback(
({ page: { index, size } }: TableChangeCallbackArguments) => {
dispatch({
type: 'userPaginatedPolicyListTable',
payload: {
pageIndex: index,
pageSize: size,
},
});
},
[dispatch]
);
const columns: Array<EuiTableFieldDataColumnType<PolicyData>> = useMemo(
() => [
{
field: 'name',
name: i18n.translate('xpack.endpoint.policyList.nameField', {
defaultMessage: 'Policy Name',
}),
render: renderPolicyNameLink,
truncateText: true,
},
{
field: 'revision',
name: i18n.translate('xpack.endpoint.policyList.revisionField', {
defaultMessage: 'Revision',
}),
dataType: 'number',
},
{
field: 'package',
name: i18n.translate('xpack.endpoint.policyList.versionField', {
defaultMessage: 'Version',
}),
render(pkg) {
return `${pkg.title} v${pkg.version}`;
},
},
{
field: 'description',
name: i18n.translate('xpack.endpoint.policyList.descriptionField', {
defaultMessage: 'Description',
}),
truncateText: true,
},
{
field: 'config_id',
name: i18n.translate('xpack.endpoint.policyList.agentConfigField', {
defaultMessage: 'Agent Configuration',
}),
render(version: string) {
return (
// eslint-disable-next-line @elastic/eui/href-or-on-click
<EuiLink
href={`${services.application.getUrlForApp('ingestManager')}#/configs/${version}`}
onClick={(ev: SyntheticEvent) => {
ev.preventDefault();
services.application.navigateToApp('ingestManager', {
path: `#/configs/${version}`,
});
}}
>
{version}
</EuiLink>
);
},
},
],
[services.application]
);
return (
<EuiPage data-test-subj="policyListPage">
<EuiPageBody>
<EuiPageContent>
<EuiPageContentHeader>
<EuiPageContentHeaderSection>
<EuiTitle size="l">
<h1 data-test-subj="policyViewTitle">
<FormattedMessage
id="xpack.endpoint.policyList.viewTitle"
defaultMessage="Policies"
/>
</h1>
</EuiTitle>
<h2>
<EuiText color="subdued" data-test-subj="policyTotalCount" size="s">
<FormattedMessage
id="xpack.endpoint.policyList.viewTitleTotalCount"
defaultMessage="{totalItemCount} Policies"
values={{ totalItemCount }}
/>
</EuiText>
</h2>
</EuiPageContentHeaderSection>
</EuiPageContentHeader>
<EuiPageContentBody>
<EuiBasicTable
items={policyItems}
columns={columns}
loading={loading}
pagination={paginationSetup}
onChange={handleTableChange}
data-test-subj="policyTable"
/>
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
);
});

View file

@ -1,21 +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 React, { memo } from 'react';
import { useLocation } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { EndpointAppLocation, AppAction } from '../types';
/**
* This component should be used above all routes, but below the Provider.
* It dispatches actions when the URL is changed.
*/
export const RouteCapture = memo(({ children }) => {
const location: EndpointAppLocation = useLocation();
const dispatch: (action: AppAction) => unknown = useDispatch();
dispatch({ type: 'userChangedUrl', payload: location });
return <>{children}</>;
});

View file

@ -1,28 +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 { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { PageId } from '../../../../common/types';
import { RoutingAction } from '../store/routing';
/**
* Dispatches a 'userNavigatedToPage' action with the given 'pageId' as the action payload.
* When the component is un-mounted, a `userNavigatedFromPage` action will be dispatched
* with the given `pageId`.
*
* @param pageId A page id
*/
export function usePageId(pageId: PageId) {
const dispatch: (action: RoutingAction) => unknown = useDispatch();
useEffect(() => {
dispatch({ type: 'userNavigatedToPage', payload: pageId });
return () => {
dispatch({ type: 'userNavigatedFromPage', payload: pageId });
};
}, [dispatch, pageId]);
}

View file

@ -1,32 +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 { cloneHttpFetchQuery } from './clone_http_fetch_query';
import { Immutable } from '../../common/types';
import { HttpFetchQuery } from '../../../../../src/core/public';
describe('cloneHttpFetchQuery', () => {
it('can clone complex queries', () => {
const query: Immutable<HttpFetchQuery> = {
a: 'a',
'1': 1,
undefined,
array: [1, 2, undefined],
};
expect(cloneHttpFetchQuery(query)).toMatchInlineSnapshot(`
Object {
"1": 1,
"a": "a",
"array": Array [
1,
2,
undefined,
],
"undefined": undefined,
}
`);
});
});

View file

@ -1,22 +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 { Immutable } from '../../common/types';
import { HttpFetchQuery } from '../../../../../src/core/public';
export function cloneHttpFetchQuery(query: Immutable<HttpFetchQuery>): HttpFetchQuery {
const clone: HttpFetchQuery = {};
for (const [key, value] of Object.entries(query)) {
if (Array.isArray(value)) {
clone[key] = [...value];
} else {
// Array.isArray is not removing ImmutableArray from the union.
clone[key] = value as string | number | boolean;
}
}
return clone;
}

View file

@ -1,26 +0,0 @@
# Introduction
Resolver renders a map in a DOM element. Items on the map are placed in 2 dimensions using arbitrary units. Like other mapping software, the map can show things at different scales. The 'camera' determines what is shown on the map.
The camera is positioned. When the user clicks-and-drags the map, the camera's position is changed. This allows the user to pan around the map and see things that would otherwise be out of view, at a given scale.
The camera determines the scale. If the scale is smaller, the viewport of the map is larger and more is visible. This allows the user to zoom in an out. On screen controls and gestures (trackpad-pinch, or CTRL-mousewheel) change the scale.
# Concepts
## Scaling
The camera scale is controlled both by the user and programatically by Resolver. There is a maximum and minimum scale value (at the time of this writing they are 0.5 and 6.) This means that the map, and things on the map, will be rendered at between 0.5 and 6 times their instrinsic dimensions.
A range control is provided so that the user can change the scale. The user can also pinch-to-zoom on Mac OS X (or use ctrl-mousewheel otherwise) to change the scale. These interactions change the `scalingFactor`. This number is between 0 and 1. It represents how zoomed-in things should be. When the `scalingFactor` is 1, the scale will be the maximum scale value. When `scalingFactor` is 0, the scale will be the minimum scale value. Otherwise we interpolate between the minimum and maximum scale factor. The rate that the scale increases between the two is controlled by `scalingFactor**zoomCurveRate` The zoom curve rate is 4 at the time of this writing. This makes it so that the change in scale is more pronounced when the user is zoomed in.
```
renderScale = minimumScale * (1 - scalingFactor**curveRate) + maximumScale * scalingFactor**curveRate;
```
## Panning
When the user clicks and drags the map, the camera is 'moved' around. This allows the user to see different things on the map. The on-screen controls provide 4 directional buttons which nudge the camera, as well as a reset button. The reset button brings the camera back where it started (0, 0).
Resolver may programatically change the position of the camera in order to bring some interesting elements into view.
## Animation
The camera can animate changes to its position. Animations usually have a short, fixed duration, such as 1 second. If the camera is moving a great deal during the animation, then things could end up moving across the screen too quickly. In this case, looking at Resolver might be disorienting. In order to combat this, Resolver may temporarily decrease the scale. By decreasing the scale, objects look futher away. Far away objects appear to move slower.

View file

@ -1,41 +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 ReactDOM from 'react-dom';
import React from 'react';
import { Provider } from 'react-redux';
import { Resolver } from './view';
import { storeFactory } from './store';
import { Embeddable } from '../../../../../../src/plugins/embeddable/public';
export class ResolverEmbeddable extends Embeddable {
public readonly type = 'resolver';
private lastRenderTarget?: Element;
public render(node: HTMLElement) {
if (this.lastRenderTarget !== undefined) {
ReactDOM.unmountComponentAtNode(this.lastRenderTarget);
}
this.lastRenderTarget = node;
const { store } = storeFactory();
ReactDOM.render(
<Provider store={store}>
<Resolver />
</Provider>,
node
);
}
public reload(): void {
throw new Error('Method not implemented.');
}
public destroy(): void {
if (this.lastRenderTarget !== undefined) {
ReactDOM.unmountComponentAtNode(this.lastRenderTarget);
}
}
}

View file

@ -1,31 +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 { i18n } from '@kbn/i18n';
import {
EmbeddableFactory,
IContainer,
EmbeddableInput,
} from '../../../../../../src/plugins/embeddable/public';
import { ResolverEmbeddable } from './embeddable';
export class ResolverEmbeddableFactory extends EmbeddableFactory {
public readonly type = 'resolver';
public async isEditable() {
return true;
}
public async create(initialInput: EmbeddableInput, parent?: IContainer) {
return new ResolverEmbeddable(initialInput, {}, parent);
}
public getDisplayName() {
return i18n.translate('xpack.endpoint.resolver.displayNameTitle', {
defaultMessage: 'Resolver',
});
}
}

View file

@ -1,8 +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.
*/
export { ResolverEmbeddableFactory } from './factory';
export { ResolverEmbeddable } from './embeddable';

View file

@ -1,19 +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.
*/
/**
* Return `value` unless it is less than `minimum`, in which case return `minimum` or unless it is greater than `maximum`, in which case return `maximum`.
*/
export function clamp(value: number, minimum: number, maximum: number) {
return Math.max(Math.min(value, maximum), minimum);
}
/**
* linearly interpolate between `a` and `b` at a ratio of `ratio`. If `ratio` is `0`, return `a`, if ratio is `1`, return `b`.
*/
export function lerp(a: number, b: number, ratio: number): number {
return a * (1 - ratio) + b * ratio;
}

View file

@ -1,21 +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 { multiply } from './matrix3';
describe('matrix3', () => {
it('can multiply two matrix3s', () => {
expect(multiply([1, 2, 3, 4, 5, 6, 7, 8, 9], [10, 11, 12, 13, 14, 15, 16, 17, 18])).toEqual([
84,
90,
96,
201,
216,
231,
318,
342,
366,
]);
});
});

View file

@ -1,56 +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 { Matrix3 } from '../types';
/**
* Return a new matrix which is the product of the first and second matrix.
*/
export function multiply(
[a11, a12, a13, a21, a22, a23, a31, a32, a33]: Matrix3,
[b11, b12, b13, b21, b22, b23, b31, b32, b33]: Matrix3
): Matrix3 {
const s11 = a11 * b11 + a12 * b21 + a13 * b31;
const s12 = a11 * b12 + a12 * b22 + a13 * b32;
const s13 = a11 * b13 + a12 * b23 + a13 * b33;
const s21 = a21 * b11 + a22 * b21 + a23 * b31;
const s22 = a21 * b12 + a22 * b22 + a23 * b32;
const s23 = a21 * b13 + a22 * b23 + a23 * b33;
const s31 = a31 * b11 + a32 * b21 + a33 * b31;
const s32 = a31 * b12 + a32 * b22 + a33 * b32;
const s33 = a31 * b13 + a32 * b23 + a33 * b33;
// prettier-ignore
return [
s11, s12, s13,
s21, s22, s23,
s31, s32, s33,
];
}
/**
* Return a new matrix which is the sum of the two passed in.
*/
export function add(
[a11, a12, a13, a21, a22, a23, a31, a32, a33]: Matrix3,
[b11, b12, b13, b21, b22, b23, b31, b32, b33]: Matrix3
): Matrix3 {
return [
a11 + b11,
a12 + b12,
a13 + b13,
a21 + b21,
a22 + b22,
a23 + b23,
a31 + b31,
a32 + b32,
a33 + b33,
];
}

View file

@ -1,14 +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 { applyMatrix3 } from './vector2';
import { scalingTransformation } from './transformation';
describe('transforms', () => {
it('applying a scale matrix to a vector2 can invert the y value', () => {
expect(applyMatrix3([1, 2], scalingTransformation([1, -1]))).toEqual([1, -2]);
});
});

View file

@ -1,115 +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 { Matrix3, Vector2 } from '../types';
/**
* The inverse of `orthographicProjection`.
*/
export function inverseOrthographicProjection(
top: number,
right: number,
bottom: number,
left: number
): Matrix3 {
let m11: number;
let m13: number;
let m22: number;
let m23: number;
/**
* If `right - left` is 0, the width is 0, so scale everything to 0
*/
if (right - left === 0) {
m11 = 0;
m13 = 0;
} else {
m11 = (right - left) / 2;
m13 = (right + left) / (right - left);
}
/**
* If `top - bottom` is 0, the height is 0, so scale everything to 0
*/
if (top - bottom === 0) {
m22 = 0;
m23 = 0;
} else {
m22 = (top - bottom) / 2;
m23 = (top + bottom) / (top - bottom);
}
return [m11, 0, m13, 0, m22, m23, 0, 0, 0];
}
/**
* Adjust x, y to be bounded, in scale, of a clipping plane defined by top, right, bottom, left.
*
* See explanation:
* https://www.scratchapixel.com/lessons/3d-basic-rendering/perspective-and-orthographic-projection-matrix
* https://en.wikipedia.org/wiki/Orthographic_projection
*/
export function orthographicProjection(
top: number,
right: number,
bottom: number,
left: number
): Matrix3 {
let m11: number;
let m13: number;
let m22: number;
let m23: number;
/**
* If `right - left` is 0, the width is 0, so scale everything to 0
*/
if (right - left === 0) {
m11 = 0;
m13 = 0;
} else {
m11 = 2 / (right - left); // adjust x scale to match ndc (-1, 1) bounds
m13 = -((right + left) / (right - left));
}
/**
* If `top - bottom` is 0, the height is 0, so scale everything to 0
*/
if (top - bottom === 0) {
m22 = 0;
m23 = 0;
} else {
m22 = top - bottom === 0 ? 0 : 2 / (top - bottom); // adjust y scale to match ndc (-1, 1) bounds
m23 = top - bottom === 0 ? 0 : -((top + bottom) / (top - bottom));
}
return [m11, 0, m13, 0, m22, m23, 0, 0, 0];
}
/**
* Returns a 2D transformation matrix that when applied to a vector will scale the vector by `x` and `y` in their respective axises.
* See https://en.wikipedia.org/wiki/Scaling_(geometry)#Matrix_representation
*/
export function scalingTransformation([x, y]: Vector2): Matrix3 {
// prettier-ignore
return [
x, 0, 0,
0, y, 0,
0, 0, 0
]
}
/**
* Returns a 2D transformation matrix that when applied to a vector will translate by `x` and `y` in their respective axises.
* See https://en.wikipedia.org/wiki/Translation_(geometry)#Matrix_representation
*/
export function translationTransformation([x, y]: Vector2): Matrix3 {
// prettier-ignore
return [
1, 0, x,
0, 1, y,
0, 0, 0
]
}

View file

@ -1,35 +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.
*/
/**
* Sequences a tree, yielding children returned by the `children` function. Sequencing is done in 'depth first preorder' fashion. See https://en.wikipedia.org/wiki/Tree_traversal#Pre-order_(NLR)
*/
export function* depthFirstPreorder<T>(root: T, children: (parent: T) => T[]): Iterable<T> {
const nodesToVisit = [root];
while (nodesToVisit.length !== 0) {
const currentNode = nodesToVisit.shift();
if (currentNode !== undefined) {
nodesToVisit.unshift(...(children(currentNode) || []));
yield currentNode;
}
}
}
/**
* Sequences a tree, yielding children returned by the `children` function. Sequencing is done in 'level order' fashion.
*/
export function* levelOrder<T>(root: T, children: (parent: T) => T[]): Iterable<T> {
let level = [root];
while (level.length !== 0) {
let nextLevel = [];
for (const node of level) {
yield node;
nextLevel.push(...(children(node) || []));
}
level = nextLevel;
nextLevel = [];
}
}

View file

@ -1,89 +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 { Vector2, Matrix3 } from '../types';
/**
* Returns a vector which is the sum of `a` and `b`.
*/
export function add(a: Vector2, b: Vector2): Vector2 {
return [a[0] + b[0], a[1] + b[1]];
}
/**
* Returns a vector which is the difference of `a` and `b`.
*/
export function subtract(a: Vector2, b: Vector2): Vector2 {
return [a[0] - b[0], a[1] - b[1]];
}
/**
* Returns a vector which is the quotient of `a` and `b`.
*/
export function divide(a: Vector2, b: Vector2): Vector2 {
return [a[0] / b[0], a[1] / b[1]];
}
/**
* Return `[ a[0] * b[0], a[1] * b[1] ]`
*/
export function multiply(a: Vector2, b: Vector2): Vector2 {
return [a[0] * b[0], a[1] * b[1]];
}
/**
* Returns a vector which is the result of applying a 2D transformation matrix to the provided vector.
*/
export function applyMatrix3([x, y]: Vector2, [m11, m12, m13, m21, m22, m23]: Matrix3): Vector2 {
return [x * m11 + y * m12 + m13, x * m21 + y * m22 + m23];
}
/**
* Returns the distance between two vectors
*/
export function distance(a: Vector2, b: Vector2) {
const [x1, y1] = a;
const [x2, y2] = b;
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
}
/**
* Returns the angle between two vectors
*/
export function angle(a: Vector2, b: Vector2) {
const deltaX = b[0] - a[0];
const deltaY = b[1] - a[1];
return Math.atan2(deltaY, deltaX);
}
/**
* Clamp `vector`'s components.
*/
export function clamp([x, y]: Vector2, [minX, minY]: Vector2, [maxX, maxY]: Vector2): Vector2 {
return [Math.max(minX, Math.min(maxX, x)), Math.max(minY, Math.min(maxY, y))];
}
/**
* Scale vector by number
*/
export function scale(a: Vector2, n: number): Vector2 {
return [a[0] * n, a[1] * n];
}
/**
* Linearly interpolate between `a` and `b`.
* `t` represents progress and:
* 0 <= `t` <= 1
*/
export function lerp(a: Vector2, b: Vector2, t: number): Vector2 {
return add(scale(a, 1 - t), scale(b, t));
}
/**
* The length of the vector
*/
export function length([x, y]: Vector2): number {
return Math.sqrt(x * x + y * y);
}

Some files were not shown because too many files have changed in this diff Show more