[Asset Management] Add support for Live queries in Osquery (#89889) (#94078)

Co-authored-by: Patryk Kopyciński <patryk.kopycinski@elastic.co>
This commit is contained in:
Kibana Machine 2021-03-09 09:43:45 -05:00 committed by GitHub
parent 198d89d1c7
commit 274a64979d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
146 changed files with 7307 additions and 793 deletions

View file

@ -1292,14 +1292,19 @@ module.exports = {
* Osquery overrides
*/
{
extends: ['eslint:recommended', 'plugin:react/recommended'],
plugins: ['react'],
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
],
plugins: ['react', '@typescript-eslint'],
files: ['x-pack/plugins/osquery/**/*.{js,mjs,ts,tsx}'],
rules: {
'arrow-body-style': ['error', 'as-needed'],
'prefer-arrow-callback': 'error',
'no-unused-vars': 'off',
'react/prop-types': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
},
},
{

View file

@ -16,7 +16,7 @@
"children": [],
"source": {
"path": "x-pack/plugins/osquery/public/types.ts",
"lineNumber": 14
"lineNumber": 18
},
"lifecycle": "setup",
"initialIsOpen": true
@ -30,7 +30,7 @@
"children": [],
"source": {
"path": "x-pack/plugins/osquery/public/types.ts",
"lineNumber": 16
"lineNumber": 20
},
"lifecycle": "start",
"initialIsOpen": true
@ -52,7 +52,7 @@
"children": [],
"source": {
"path": "x-pack/plugins/osquery/server/types.ts",
"lineNumber": 15
"lineNumber": 16
},
"lifecycle": "setup",
"initialIsOpen": true
@ -66,7 +66,7 @@
"children": [],
"source": {
"path": "x-pack/plugins/osquery/server/types.ts",
"lineNumber": 17
"lineNumber": 18
},
"lifecycle": "start",
"initialIsOpen": true
@ -134,7 +134,7 @@
"lineNumber": 11
},
"signature": [
"\"osquery\""
"\"Osquery\""
],
"initialIsOpen": false
}

View file

@ -277,6 +277,7 @@
"react-intl": "^2.8.0",
"react-is": "^16.8.0",
"react-moment-proptypes": "^1.7.0",
"react-query": "^3.12.0",
"react-redux": "^7.2.0",
"react-resizable": "^1.7.5",
"react-router": "^5.2.0",

View file

@ -0,0 +1,177 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as t from 'io-ts';
import { left, right, Either } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';
import { exactCheck, findDifferencesRecursive } from './exact_check';
import { foldLeftRight, getPaths } from './test_utils';
describe('exact_check', () => {
test('it returns an error if given extra object properties', () => {
const someType = t.exact(
t.type({
a: t.string,
})
);
const payload = { a: 'test', b: 'test' };
const decoded = someType.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['invalid keys "b"']);
expect(message.schema).toEqual({});
});
test('it returns an error if the data type is not as expected', () => {
type UnsafeCastForTest = Either<
t.Errors,
{
a: number;
}
>;
const someType = t.exact(
t.type({
a: t.string,
})
);
const payload = { a: 1 };
const decoded = someType.decode(payload);
const checked = exactCheck(payload, decoded as UnsafeCastForTest);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['Invalid value "1" supplied to "a"']);
expect(message.schema).toEqual({});
});
test('it does NOT return an error if given normal object properties', () => {
const someType = t.exact(
t.type({
a: t.string,
})
);
const payload = { a: 'test' };
const decoded = someType.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it will return an existing error and not validate', () => {
const payload = { a: 'test' };
const validationError: t.ValidationError = {
value: 'Some existing error',
context: [],
message: 'some error',
};
const error: t.Errors = [validationError];
const leftValue = left(error);
const checked = exactCheck(payload, leftValue);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['some error']);
expect(message.schema).toEqual({});
});
test('it will work with a regular "right" payload without any decoding', () => {
const payload = { a: 'test' };
const rightValue = right(payload);
const checked = exactCheck(payload, rightValue);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual({ a: 'test' });
});
test('it will work with decoding a null payload when the schema expects a null', () => {
const someType = t.union([
t.exact(
t.type({
a: t.string,
})
),
t.null,
]);
const payload = null;
const decoded = someType.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(null);
});
test('it should find no differences recursively with two empty objects', () => {
const difference = findDifferencesRecursive({}, {});
expect(difference).toEqual([]);
});
test('it should find a single difference with two objects with different keys', () => {
const difference = findDifferencesRecursive({ a: 1 }, { b: 1 });
expect(difference).toEqual(['a']);
});
test('it should find a two differences with two objects with multiple different keys', () => {
const difference = findDifferencesRecursive({ a: 1, c: 1 }, { b: 1 });
expect(difference).toEqual(['a', 'c']);
});
test('it should find no differences with two objects with the same keys', () => {
const difference = findDifferencesRecursive({ a: 1, b: 1 }, { a: 1, b: 1 });
expect(difference).toEqual([]);
});
test('it should find a difference with two deep objects with different same keys', () => {
const difference = findDifferencesRecursive({ a: 1, b: { c: 1 } }, { a: 1, b: { d: 1 } });
expect(difference).toEqual(['c']);
});
test('it should find a difference within an array', () => {
const difference = findDifferencesRecursive({ a: 1, b: [{ c: 1 }] }, { a: 1, b: [{ a: 1 }] });
expect(difference).toEqual(['c']);
});
test('it should find a no difference when using arrays that are identical', () => {
const difference = findDifferencesRecursive({ a: 1, b: [{ c: 1 }] }, { a: 1, b: [{ c: 1 }] });
expect(difference).toEqual([]);
});
test('it should find differences when one has an array and the other does not', () => {
const difference = findDifferencesRecursive({ a: 1, b: [{ c: 1 }] }, { a: 1 });
expect(difference).toEqual(['b', '[{"c":1}]']);
});
test('it should find differences when one has an deep object and the other does not', () => {
const difference = findDifferencesRecursive({ a: 1, b: { c: 1 } }, { a: 1 });
expect(difference).toEqual(['b', '{"c":1}']);
});
test('it should find differences when one has a deep object with multiple levels and the other does not', () => {
const difference = findDifferencesRecursive({ a: 1, b: { c: { d: 1 } } }, { a: 1 });
expect(difference).toEqual(['b', '{"c":{"d":1}}']);
});
test('it tests two deep objects as the same with no key differences', () => {
const difference = findDifferencesRecursive(
{ a: 1, b: { c: { d: 1 } } },
{ a: 1, b: { c: { d: 1 } } }
);
expect(difference).toEqual([]);
});
test('it tests two deep objects with just one deep key difference', () => {
const difference = findDifferencesRecursive(
{ a: 1, b: { c: { d: 1 } } },
{ a: 1, b: { c: { e: 1 } } }
);
expect(difference).toEqual(['d']);
});
test('it should not find any differences when the original and decoded are both null', () => {
const difference = findDifferencesRecursive(null, null);
expect(difference).toEqual([]);
});
});

View file

@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as t from 'io-ts';
import { left, Either, fold, right } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';
import { isObject, get } from 'lodash/fp';
/**
* Given an original object and a decoded object this will return an error
* if and only if the original object has additional keys that the decoded
* object does not have. If the original decoded already has an error, then
* this will return the error as is and not continue.
*
* NOTE: You MUST use t.exact(...) for this to operate correctly as your schema
* needs to remove additional keys before the compare
*
* You might not need this in the future if the below issue is solved:
* https://github.com/gcanti/io-ts/issues/322
*
* @param original The original to check if it has additional keys
* @param decoded The decoded either which has either an existing error or the
* decoded object which could have additional keys stripped from it.
*/
export const exactCheck = <T>(
original: unknown,
decoded: Either<t.Errors, T>
): Either<t.Errors, T> => {
const onLeft = (errors: t.Errors): Either<t.Errors, T> => left(errors);
const onRight = (decodedValue: T): Either<t.Errors, T> => {
const differences = findDifferencesRecursive(original, decodedValue);
if (differences.length !== 0) {
const validationError: t.ValidationError = {
value: differences,
context: [],
message: `invalid keys "${differences.join(',')}"`,
};
const error: t.Errors = [validationError];
return left(error);
} else {
return right(decodedValue);
}
};
return pipe(decoded, fold(onLeft, onRight));
};
export const findDifferencesRecursive = <T>(original: unknown, decodedValue: T): string[] => {
if (decodedValue === null && original === null) {
// both the decodedValue and the original are null which indicates that they are equal
// so do not report differences
return [];
} else if (decodedValue == null) {
try {
// It is null and painful when the original contains an object or an array
// the the decoded value does not have.
return [JSON.stringify(original)];
} catch (err) {
return ['circular reference'];
}
} else if (typeof original !== 'object' || original == null) {
// We are not an object or null so do not report differences
return [];
} else {
const decodedKeys = Object.keys(decodedValue);
const differences = Object.keys(original).flatMap((originalKey) => {
const foundKey = decodedKeys.some((key) => key === originalKey);
const topLevelKey = foundKey ? [] : [originalKey];
// I use lodash to cheat and get an any (not going to lie ;-))
const valueObjectOrArrayOriginal = get(originalKey, original);
const valueObjectOrArrayDecoded = get(originalKey, decodedValue);
if (isObject(valueObjectOrArrayOriginal)) {
return [
...topLevelKey,
...findDifferencesRecursive(valueObjectOrArrayOriginal, valueObjectOrArrayDecoded),
];
} else if (Array.isArray(valueObjectOrArrayOriginal)) {
return [
...topLevelKey,
...valueObjectOrArrayOriginal.flatMap((arrayElement, index) =>
findDifferencesRecursive(arrayElement, get(index, valueObjectOrArrayDecoded))
),
];
} else {
return topLevelKey;
}
});
return differences;
}
};

View file

@ -0,0 +1,188 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as t from 'io-ts';
import { formatErrors } from './format_errors';
describe('utils', () => {
test('returns an empty error message string if there are no errors', () => {
const errors: t.Errors = [];
const output = formatErrors(errors);
expect(output).toEqual([]);
});
test('returns a single error message if given one', () => {
const validationError: t.ValidationError = {
value: 'Some existing error',
context: [],
message: 'some error',
};
const errors: t.Errors = [validationError];
const output = formatErrors(errors);
expect(output).toEqual(['some error']);
});
test('returns a two error messages if given two', () => {
const validationError1: t.ValidationError = {
value: 'Some existing error 1',
context: [],
message: 'some error 1',
};
const validationError2: t.ValidationError = {
value: 'Some existing error 2',
context: [],
message: 'some error 2',
};
const errors: t.Errors = [validationError1, validationError2];
const output = formatErrors(errors);
expect(output).toEqual(['some error 1', 'some error 2']);
});
test('it filters out duplicate error messages', () => {
const validationError1: t.ValidationError = {
value: 'Some existing error 1',
context: [],
message: 'some error 1',
};
const validationError2: t.ValidationError = {
value: 'Some existing error 1',
context: [],
message: 'some error 1',
};
const errors: t.Errors = [validationError1, validationError2];
const output = formatErrors(errors);
expect(output).toEqual(['some error 1']);
});
test('will use message before context if it is set', () => {
const context: t.Context = ([{ key: 'some string key' }] as unknown) as t.Context;
const validationError1: t.ValidationError = {
value: 'Some existing error 1',
context,
message: 'I should be used first',
};
const errors: t.Errors = [validationError1];
const output = formatErrors(errors);
expect(output).toEqual(['I should be used first']);
});
test('will use context entry of a single string', () => {
const context: t.Context = ([{ key: 'some string key' }] as unknown) as t.Context;
const validationError1: t.ValidationError = {
value: 'Some existing error 1',
context,
};
const errors: t.Errors = [validationError1];
const output = formatErrors(errors);
expect(output).toEqual(['Invalid value "Some existing error 1" supplied to "some string key"']);
});
test('will use two context entries of two strings', () => {
const context: t.Context = ([
{ key: 'some string key 1' },
{ key: 'some string key 2' },
] as unknown) as t.Context;
const validationError1: t.ValidationError = {
value: 'Some existing error 1',
context,
};
const errors: t.Errors = [validationError1];
const output = formatErrors(errors);
expect(output).toEqual([
'Invalid value "Some existing error 1" supplied to "some string key 1,some string key 2"',
]);
});
test('will filter out and not use any strings of numbers', () => {
const context: t.Context = ([
{ key: '5' },
{ key: 'some string key 2' },
] as unknown) as t.Context;
const validationError1: t.ValidationError = {
value: 'Some existing error 1',
context,
};
const errors: t.Errors = [validationError1];
const output = formatErrors(errors);
expect(output).toEqual([
'Invalid value "Some existing error 1" supplied to "some string key 2"',
]);
});
test('will filter out and not use null', () => {
const context: t.Context = ([
{ key: null },
{ key: 'some string key 2' },
] as unknown) as t.Context;
const validationError1: t.ValidationError = {
value: 'Some existing error 1',
context,
};
const errors: t.Errors = [validationError1];
const output = formatErrors(errors);
expect(output).toEqual([
'Invalid value "Some existing error 1" supplied to "some string key 2"',
]);
});
test('will filter out and not use empty strings', () => {
const context: t.Context = ([
{ key: '' },
{ key: 'some string key 2' },
] as unknown) as t.Context;
const validationError1: t.ValidationError = {
value: 'Some existing error 1',
context,
};
const errors: t.Errors = [validationError1];
const output = formatErrors(errors);
expect(output).toEqual([
'Invalid value "Some existing error 1" supplied to "some string key 2"',
]);
});
test('will use a name context if it cannot find a keyContext', () => {
const context: t.Context = ([
{ key: '' },
{ key: '', type: { name: 'someName' } },
] as unknown) as t.Context;
const validationError1: t.ValidationError = {
value: 'Some existing error 1',
context,
};
const errors: t.Errors = [validationError1];
const output = formatErrors(errors);
expect(output).toEqual(['Invalid value "Some existing error 1" supplied to "someName"']);
});
test('will return an empty string if name does not exist but type does', () => {
const context: t.Context = ([{ key: '' }, { key: '', type: {} }] as unknown) as t.Context;
const validationError1: t.ValidationError = {
value: 'Some existing error 1',
context,
};
const errors: t.Errors = [validationError1];
const output = formatErrors(errors);
expect(output).toEqual(['Invalid value "Some existing error 1" supplied to ""']);
});
test('will stringify an error value', () => {
const context: t.Context = ([
{ key: '' },
{ key: 'some string key 2' },
] as unknown) as t.Context;
const validationError1: t.ValidationError = {
value: { foo: 'some error' },
context,
};
const errors: t.Errors = [validationError1];
const output = formatErrors(errors);
expect(output).toEqual([
'Invalid value "{"foo":"some error"}" supplied to "some string key 2"',
]);
});
});

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as t from 'io-ts';
import { isObject } from 'lodash/fp';
export const formatErrors = (errors: t.Errors): string[] => {
const err = errors.map((error) => {
if (error.message != null) {
return error.message;
} else {
const keyContext = error.context
.filter(
(entry) => entry.key != null && !Number.isInteger(+entry.key) && entry.key.trim() !== ''
)
.map((entry) => entry.key)
.join(',');
const nameContext = error.context.find((entry) => entry.type?.name?.length > 0);
const suppliedValue =
keyContext !== '' ? keyContext : nameContext != null ? nameContext.type.name : '';
const value = isObject(error.value) ? JSON.stringify(error.value) : error.value;
return `Invalid value "${value}" supplied to "${suppliedValue}"`;
}
});
return [...new Set(err)];
};

View file

@ -8,4 +8,4 @@
export * from './constants';
export const PLUGIN_ID = 'osquery';
export const PLUGIN_NAME = 'osquery';
export const PLUGIN_NAME = 'Osquery';

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './schemas';

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as t from 'io-ts';
export const name = t.string;
export type Name = t.TypeOf<typeof name>;
export const nameOrUndefined = t.union([name, t.undefined]);
export type NameOrUndefined = t.TypeOf<typeof nameOrUndefined>;
export const description = t.string;
export type Description = t.TypeOf<typeof description>;
export const descriptionOrUndefined = t.union([description, t.undefined]);
export type DescriptionOrUndefined = t.TypeOf<typeof descriptionOrUndefined>;
export const platform = t.string;
export type Platform = t.TypeOf<typeof platform>;
export const platformOrUndefined = t.union([platform, t.undefined]);
export type PlatformOrUndefined = t.TypeOf<typeof platformOrUndefined>;
export const query = t.string;
export type Query = t.TypeOf<typeof query>;
export const queryOrUndefined = t.union([query, t.undefined]);
export type QueryOrUndefined = t.TypeOf<typeof queryOrUndefined>;

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as t from 'io-ts';
import { name, description, Description, platform, query } from '../../common/schemas';
import { RequiredKeepUndefined } from '../../../types';
export const createSavedQueryRequestSchema = t.type({
name,
description,
platform,
query,
});
export type CreateSavedQueryRequestSchema = t.OutputOf<typeof createSavedQueryRequestSchema>;
// This type is used after a decode since some things are defaults after a decode.
export type CreateSavedQueryRequestSchemaDecoded = Omit<
RequiredKeepUndefined<t.TypeOf<typeof createSavedQueryRequestSchema>>,
'description'
> & {
description: Description;
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './create_saved_query_request_schema';

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DefaultUuid } from './default_uuid';
import { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '../../test_utils';
describe('default_uuid', () => {
test('it should validate a regular string', () => {
const payload = '1';
const decoded = DefaultUuid.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should not validate a number', () => {
const payload = 5;
const decoded = DefaultUuid.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to "DefaultUuid"']);
expect(message.schema).toEqual({});
});
test('it should return a default of a uuid', () => {
const payload = null;
const decoded = DefaultUuid.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i
);
});
});

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as t from 'io-ts';
import { Either } from 'fp-ts/lib/Either';
import uuid from 'uuid';
import { NonEmptyString } from './non_empty_string';
/**
* Types the DefaultUuid as:
* - If null or undefined, then a default string uuid.v4() will be
* created otherwise it will be checked just against an empty string
*/
export const DefaultUuid = new t.Type<string, string | undefined, unknown>(
'DefaultUuid',
t.string.is,
(input, context): Either<t.Errors, string> =>
input == null ? t.success(uuid.v4()) : NonEmptyString.validate(input, context),
t.identity
);

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { NonEmptyString } from './non_empty_string';
import { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '../../test_utils';
describe('non_empty_string', () => {
test('it should validate a regular string', () => {
const payload = '1';
const decoded = NonEmptyString.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should not validate a number', () => {
const payload = 5;
const decoded = NonEmptyString.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "5" supplied to "NonEmptyString"',
]);
expect(message.schema).toEqual({});
});
test('it should not validate an empty string', () => {
const payload = '';
const decoded = NonEmptyString.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "" supplied to "NonEmptyString"',
]);
expect(message.schema).toEqual({});
});
test('it should not validate empty spaces', () => {
const payload = ' ';
const decoded = NonEmptyString.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value " " supplied to "NonEmptyString"',
]);
expect(message.schema).toEqual({});
});
});

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as t from 'io-ts';
import { Either } from 'fp-ts/lib/Either';
/**
* Types the NonEmptyString as:
* - A string that is not empty
*/
export const NonEmptyString = new t.Type<string, string, unknown>(
'NonEmptyString',
t.string.is,
(input, context): Either<t.Errors, string> => {
if (typeof input === 'string' && input.trim() !== '') {
return t.success(input);
} else {
return t.failure(input, context);
}
},
t.identity
);
export type NonEmptyStringC = typeof NonEmptyString;

View file

@ -21,9 +21,10 @@ export interface ActionsStrategyResponse extends IEsSearchResponse {
inspect?: Maybe<Inspect>;
}
export type ActionsRequestOptions = RequestOptionsPaginated<{}>;
export type ActionsRequestOptions = RequestOptionsPaginated;
export interface ActionDetailsStrategyResponse extends IEsSearchResponse {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
actionDetails: Record<string, any>;
inspect?: Maybe<Inspect>;
}

View file

@ -18,4 +18,4 @@ export interface AgentsStrategyResponse extends IEsSearchResponse {
inspect?: Maybe<Inspect>;
}
export type AgentsRequestOptions = RequestOptionsPaginated<{}>;
export type AgentsRequestOptions = RequestOptionsPaginated;

View file

@ -20,6 +20,7 @@ export interface ResultsStrategyResponse extends IEsSearchResponse {
inspect?: Maybe<Inspect>;
}
export interface ResultsRequestOptions extends RequestOptionsPaginated<{}> {
export interface ResultsRequestOptions extends RequestOptionsPaginated {
actionId: string;
agentId?: string;
}

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as t from 'io-ts';
import { fold } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/pipeable';
import { formatErrors } from './format_errors';
interface Message<T> {
errors: t.Errors;
schema: T | {};
}
const onLeft = <T>(errors: t.Errors): Message<T> => {
return { errors, schema: {} };
};
const onRight = <T>(schema: T): Message<T> => {
return {
errors: [],
schema,
};
};
export const foldLeftRight = fold(onLeft, onRight);
/**
* Convenience utility to keep the error message handling within tests to be
* very concise.
* @param validation The validation to get the errors from
*/
export const getPaths = <A>(validation: t.Validation<A>): string[] => {
return pipe(
validation,
fold(
(errors) => formatErrors(errors),
() => ['no errors']
)
);
};
/**
* Convenience utility to remove text appended to links by EUI
*/
export const removeExternalLinkText = (str: string): string =>
str.replace(/\(opens in a new tab or window\)/g, '');

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const savedQuerySavedObjectType = 'osquery-saved-query';
export const packSavedObjectType = 'osquery-pack';
export type SavedObjectType = 'osquery-saved-query' | 'osquery-pack';
/**
* This makes any optional property the same as Required<T> would but also has the
* added benefit of keeping your undefined.
*
* For example:
* type A = RequiredKeepUndefined<{ a?: undefined; b: number }>;
*
* will yield a type of:
* type A = { a: undefined; b: number; }
*
*/
export type RequiredKeepUndefined<T> = { [K in keyof T]-?: [T[K]] } extends infer U
? U extends Record<keyof U, [unknown]>
? { [K in keyof U]: U[K][0] }
: never
: never;

View file

@ -17,10 +17,12 @@
"kibanaReact"
],
"requiredPlugins": [
"actions",
"data",
"dataEnhanced",
"fleet",
"navigation"
"navigation",
"triggersActionsUi"
],
"server": true,
"ui": true,

View file

@ -5,12 +5,23 @@
* 2.0.
*/
import { isEmpty, isEqual, keys, map } from 'lodash/fp';
import { EuiDataGrid, EuiDataGridProps, EuiDataGridColumn, EuiDataGridSorting } from '@elastic/eui';
import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react';
import { find, map } from 'lodash/fp';
import {
EuiDataGrid,
EuiDataGridProps,
EuiDataGridColumn,
EuiDataGridSorting,
EuiHealth,
EuiIcon,
EuiLink,
} from '@elastic/eui';
import React, { createContext, useState, useCallback, useContext, useMemo } from 'react';
import { useAllResults } from './use_action_results';
import { useAllAgents } from './../agents/use_all_agents';
import { useActionResults } from './use_action_results';
import { useAllResults } from '../results/use_all_results';
import { Direction, ResultEdges } from '../../common/search_strategy';
import { useRouterNavigate } from '../common/lib/kibana';
const DataContext = createContext<ResultEdges>([]);
@ -34,12 +45,38 @@ const ActionResultsTableComponent: React.FC<ActionResultsTableProps> = ({ action
[setPagination]
);
const [columns, setColumns] = useState<EuiDataGridColumn[]>([]);
const [columns] = useState<EuiDataGridColumn[]>([
{
id: 'status',
displayAsText: 'status',
defaultSortDirection: Direction.asc,
},
{
id: 'rows_count',
displayAsText: '# rows',
defaultSortDirection: Direction.asc,
},
{
id: 'agent_status',
displayAsText: 'online',
defaultSortDirection: Direction.asc,
},
{
id: 'agent',
displayAsText: 'agent',
defaultSortDirection: Direction.asc,
},
{
id: '@timestamp',
displayAsText: '@timestamp',
defaultSortDirection: Direction.asc,
},
]);
// ** Sorting config
const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]);
const [, { results, totalCount }] = useAllResults({
const { data: actionResultsData } = useActionResults({
actionId,
activePage: pagination.pageIndex,
limit: pagination.pageSize,
@ -47,23 +84,85 @@ const ActionResultsTableComponent: React.FC<ActionResultsTableProps> = ({ action
sortField: '@timestamp',
});
// Column visibility
const [visibleColumns, setVisibleColumns] = useState<string[]>([]); // initialize to the full set of columns
const [visibleColumns, setVisibleColumns] = useState<string[]>(() => map('id', columns)); // initialize to the full set of columns
const columnVisibility = useMemo(() => ({ visibleColumns, setVisibleColumns }), [
visibleColumns,
setVisibleColumns,
]);
const { data: agentsData } = useAllAgents({
activePage: 0,
limit: 1000,
direction: Direction.desc,
sortField: 'updated_at',
});
const renderCellValue: EuiDataGridProps['renderCellValue'] = useMemo(
() => ({ rowIndex, columnId, setCellProps }) => {
() => ({ rowIndex, columnId }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const data = useContext(DataContext);
const value = data[rowIndex].fields[columnId];
const value = data[rowIndex];
return !isEmpty(value) ? value : '-';
if (columnId === 'status') {
// eslint-disable-next-line react-hooks/rules-of-hooks
const linkProps = useRouterNavigate(
`/live_query/${actionId}/results/${value.fields.agent_id[0]}`
);
return (
<>
<EuiIcon type="checkInCircleFilled" />
<EuiLink {...linkProps}>{'View results'}</EuiLink>
</>
);
}
if (columnId === 'rows_count') {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { data: allResultsData } = useAllResults({
actionId,
agentId: value.fields.agent_id[0],
activePage: pagination.pageIndex,
limit: pagination.pageSize,
direction: Direction.asc,
sortField: '@timestamp',
});
// @ts-expect-error update types
return allResultsData?.totalCount ?? '-';
}
if (columnId === 'agent_status') {
const agentIdValue = value.fields.agent_id[0];
// @ts-expect-error update types
const agent = find(['_id', agentIdValue], agentsData?.agents);
const online = agent?.active;
const color = online ? 'success' : 'danger';
const label = online ? 'Online' : 'Offline';
return <EuiHealth color={color}>{label}</EuiHealth>;
}
if (columnId === 'agent') {
const agentIdValue = value.fields.agent_id[0];
// @ts-expect-error update types
const agent = find(['_id', agentIdValue], agentsData?.agents);
const agentName = agent?.local_metadata.host.name;
// eslint-disable-next-line react-hooks/rules-of-hooks
const linkProps = useRouterNavigate(`/live_query/${actionId}/results/${agentIdValue}`);
return (
<EuiLink {...linkProps}>{`(${agent?.local_metadata.os.name}) ${agentName}`}</EuiLink>
);
}
if (columnId === '@timestamp') {
return value.fields['@timestamp'];
}
return '-';
},
[]
// @ts-expect-error update types
[actionId, agentsData?.agents, pagination.pageIndex, pagination.pageSize]
);
const tableSorting: EuiDataGridSorting = useMemo(
@ -81,31 +180,19 @@ const ActionResultsTableComponent: React.FC<ActionResultsTableProps> = ({ action
[onChangeItemsPerPage, onChangePage, pagination]
);
useEffect(() => {
const newColumns = keys(results[0]?.fields)
.sort()
.map((fieldName) => ({
id: fieldName,
displayAsText: fieldName.split('.')[1],
defaultSortDirection: Direction.asc,
}));
if (!isEqual(columns, newColumns)) {
setColumns(newColumns);
setVisibleColumns(map('id', newColumns));
}
}, [columns, results]);
return (
<DataContext.Provider value={results}>
// @ts-expect-error update types
<DataContext.Provider value={actionResultsData?.results}>
<EuiDataGrid
aria-label="Osquery results"
columns={columns}
columnVisibility={columnVisibility}
rowCount={totalCount}
// @ts-expect-error update types
rowCount={actionResultsData?.totalCount}
renderCellValue={renderCellValue}
sorting={tableSorting}
pagination={tablePagination}
height="300px"
/>
</DataContext.Provider>
);

View file

@ -7,10 +7,16 @@
import { i18n } from '@kbn/i18n';
export const ERROR_ALL_RESULTS = i18n.translate('xpack.osquery.results.errorSearchDescription', {
defaultMessage: `An error has occurred on all results search`,
});
export const ERROR_ACTION_RESULTS = i18n.translate(
'xpack.osquery.action_results.errorSearchDescription',
{
defaultMessage: `An error has occurred on action results search`,
}
);
export const FAIL_ALL_RESULTS = i18n.translate('xpack.osquery.results.failSearchDescription', {
defaultMessage: `Failed to fetch results`,
});
export const FAIL_ACTION_RESULTS = i18n.translate(
'xpack.osquery.action_results.failSearchDescription',
{
defaultMessage: `Failed to fetch action results`,
}
);

View file

@ -6,14 +6,14 @@
*/
import deepEqual from 'fast-deep-equal';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery } from 'react-query';
import { createFilter } from '../common/helpers';
import { useKibana } from '../common/lib/kibana';
import {
ResultEdges,
PageInfoPaginated,
DocValueFields,
OsqueryQueries,
ResultsRequestOptions,
ResultsStrategyResponse,
@ -21,13 +21,8 @@ import {
} from '../../common/search_strategy';
import { ESTermQuery } from '../../common/typed_json';
import * as i18n from './translations';
import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common';
import { AbortError } from '../../../../../src/plugins/kibana_utils/common';
import { generateTablePaginationOptions, getInspectResponse, InspectResponse } from './helpers';
const ID = 'resultsAllQuery';
export interface ResultsArgs {
results: ResultEdges;
id: string;
@ -37,103 +32,50 @@ export interface ResultsArgs {
totalCount: number;
}
interface UseAllResults {
interface UseActionResults {
actionId: string;
activePage: number;
direction: Direction;
limit: number;
sortField: string;
docValueFields?: DocValueFields[];
filterQuery?: ESTermQuery | string;
skip?: boolean;
}
export const useAllResults = ({
export const useActionResults = ({
actionId,
activePage,
direction,
limit,
sortField,
docValueFields,
filterQuery,
skip = false,
}: UseAllResults): [boolean, ResultsArgs] => {
const { data, notifications } = useKibana().services;
}: UseActionResults) => {
const { data } = useKibana().services;
const abortCtrl = useRef(new AbortController());
const [loading, setLoading] = useState(false);
const [resultsRequest, setHostRequest] = useState<ResultsRequestOptions | null>(null);
const [resultsResponse, setResultsResponse] = useState<ResultsArgs>({
results: [],
id: ID,
inspect: {
dsl: [],
response: [],
},
isInspected: false,
pageInfo: {
activePage: 0,
fakeTotalCount: 0,
showMorePagesIndicator: false,
},
totalCount: -1,
});
const response = useQuery(
['actionResults', { actionId, activePage, direction, limit, sortField }],
async () => {
if (!resultsRequest) return Promise.resolve();
const resultsSearch = useCallback(
(request: ResultsRequestOptions | null) => {
if (request == null || skip) {
return;
}
const responseData = await data.search
.search<ResultsRequestOptions, ResultsStrategyResponse>(resultsRequest, {
strategy: 'osquerySearchStrategy',
})
.toPromise();
let didCancel = false;
const asyncSearch = async () => {
abortCtrl.current = new AbortController();
setLoading(true);
const searchSubscription$ = data.search
.search<ResultsRequestOptions, ResultsStrategyResponse>(request, {
strategy: 'osquerySearchStrategy',
abortSignal: abortCtrl.current.signal,
})
.subscribe({
next: (response) => {
if (isCompleteResponse(response)) {
if (!didCancel) {
setLoading(false);
setResultsResponse((prevResponse) => ({
...prevResponse,
results: response.edges,
inspect: getInspectResponse(response, prevResponse.inspect),
pageInfo: response.pageInfo,
totalCount: response.totalCount,
}));
}
searchSubscription$.unsubscribe();
} else if (isErrorResponse(response)) {
if (!didCancel) {
setLoading(false);
}
// TODO: Make response error status clearer
notifications.toasts.addWarning(i18n.ERROR_ALL_RESULTS);
searchSubscription$.unsubscribe();
}
},
error: (msg) => {
if (!(msg instanceof AbortError)) {
notifications.toasts.addDanger({ title: i18n.FAIL_ALL_RESULTS, text: msg.message });
}
},
});
};
abortCtrl.current.abort();
asyncSearch();
return () => {
didCancel = true;
abortCtrl.current.abort();
return {
...responseData,
results: responseData.edges,
inspect: getInspectResponse(responseData, {} as InspectResponse),
};
},
[data.search, notifications.toasts, skip]
{
refetchInterval: 1000,
enabled: !skip && !!resultsRequest,
}
);
useEffect(() => {
@ -141,7 +83,6 @@ export const useAllResults = ({
const myRequest = {
...(prevRequest ?? {}),
actionId,
docValueFields: docValueFields ?? [],
factoryQueryType: OsqueryQueries.actionResults,
filterQuery: createFilter(filterQuery),
pagination: generateTablePaginationOptions(activePage, limit),
@ -155,11 +96,7 @@ export const useAllResults = ({
}
return prevRequest;
});
}, [actionId, activePage, direction, docValueFields, filterQuery, limit, sortField]);
}, [actionId, activePage, direction, filterQuery, limit, sortField]);
useEffect(() => {
resultsSearch(resultsRequest);
}, [resultsRequest, resultsSearch]);
return [loading, resultsResponse];
return response;
};

View file

@ -6,11 +6,19 @@
*/
import { isEmpty, isEqual, keys, map } from 'lodash/fp';
import { EuiDataGrid, EuiDataGridProps, EuiDataGridColumn, EuiDataGridSorting } from '@elastic/eui';
import {
EuiLink,
EuiDataGrid,
EuiDataGridProps,
EuiDataGridColumn,
EuiDataGridSorting,
EuiLoadingContent,
} from '@elastic/eui';
import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react';
import { useAllActions } from './use_all_actions';
import { ActionEdges, Direction } from '../../common/search_strategy';
import { useRouterNavigate } from '../common/lib/kibana';
const DataContext = createContext<ActionEdges>([]);
@ -35,10 +43,10 @@ const ActionsTableComponent = () => {
// ** Sorting config
const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]);
const [, { actions, totalCount }] = useAllActions({
const { isLoading: actionsLoading, data: actionsData } = useAllActions({
activePage: pagination.pageIndex,
limit: pagination.pageSize,
direction: Direction.asc,
direction: Direction.desc,
sortField: '@timestamp',
});
@ -50,15 +58,22 @@ const ActionsTableComponent = () => {
setVisibleColumns,
]);
const renderCellValue: EuiDataGridProps['renderCellValue'] = useMemo(() => {
return ({ rowIndex, columnId, setCellProps }) => {
const renderCellValue: EuiDataGridProps['renderCellValue'] = useMemo(
() => ({ rowIndex, columnId }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const data = useContext(DataContext);
const value = data[rowIndex].fields[columnId];
if (columnId === 'action_id') {
// eslint-disable-next-line react-hooks/rules-of-hooks
const linkProps = useRouterNavigate(`/live_query/${value}`);
return <EuiLink {...linkProps}>{value}</EuiLink>;
}
return !isEmpty(value) ? value : '-';
};
}, []);
},
[]
);
const tableSorting: EuiDataGridSorting = useMemo(
() => ({ columns: sortingColumns, onSort: setSortingColumns }),
@ -76,7 +91,8 @@ const ActionsTableComponent = () => {
);
useEffect(() => {
const newColumns = keys(actions[0]?.fields)
// @ts-expect-error update types
const newColumns = keys(actionsData?.actions[0]?.fields)
.sort()
.map((fieldName) => ({
id: fieldName,
@ -88,15 +104,23 @@ const ActionsTableComponent = () => {
setColumns(newColumns);
setVisibleColumns(map('id', newColumns));
}
}, [columns, actions]);
// @ts-expect-error update types
}, [columns, actionsData?.actions]);
if (actionsLoading) {
return <EuiLoadingContent lines={10} />;
}
return (
<DataContext.Provider value={actions}>
// @ts-expect-error update types
// eslint-disable-next-line react-perf/jsx-no-new-array-as-prop
<DataContext.Provider value={actionsData?.actions ?? []}>
<EuiDataGrid
aria-label="Osquery actions"
columns={columns}
columnVisibility={columnVisibility}
rowCount={totalCount}
// @ts-expect-error update types
rowCount={actionsData?.totalCount ?? 0}
renderCellValue={renderCellValue}
sorting={tableSorting}
pagination={tablePagination}

View file

@ -6,25 +6,20 @@
*/
import deepEqual from 'fast-deep-equal';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery } from 'react-query';
import { createFilter } from '../common/helpers';
import { useKibana } from '../common/lib/kibana';
import {
DocValueFields,
OsqueryQueries,
ActionDetailsRequestOptions,
ActionDetailsStrategyResponse,
} from '../../common/search_strategy';
import { ESTermQuery } from '../../common/typed_json';
import * as i18n from './translations';
import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common';
import { AbortError } from '../../../../../src/plugins/kibana_utils/common';
import { getInspectResponse, InspectResponse } from './helpers';
const ID = 'actionDetailsQuery';
export interface ActionDetailsArgs {
actionDetails: Record<string, string>;
id: string;
@ -34,88 +29,34 @@ export interface ActionDetailsArgs {
interface UseActionDetails {
actionId: string;
docValueFields?: DocValueFields[];
filterQuery?: ESTermQuery | string;
skip?: boolean;
}
export const useActionDetails = ({
actionId,
docValueFields,
filterQuery,
skip = false,
}: UseActionDetails): [boolean, ActionDetailsArgs] => {
const { data, notifications } = useKibana().services;
export const useActionDetails = ({ actionId, filterQuery, skip = false }: UseActionDetails) => {
const { data } = useKibana().services;
const abortCtrl = useRef(new AbortController());
const [loading, setLoading] = useState(false);
const [actionDetailsRequest, setHostRequest] = useState<ActionDetailsRequestOptions | null>(null);
const [actionDetailsResponse, setActionDetailsResponse] = useState<ActionDetailsArgs>({
actionDetails: {},
id: ID,
inspect: {
dsl: [],
response: [],
},
isInspected: false,
});
const response = useQuery(
['action', { actionId }],
async () => {
if (!actionDetailsRequest) return Promise.resolve();
const actionDetailsSearch = useCallback(
(request: ActionDetailsRequestOptions | null) => {
if (request == null || skip) {
return;
}
const responseData = await data.search
.search<ActionDetailsRequestOptions, ActionDetailsStrategyResponse>(actionDetailsRequest, {
strategy: 'osquerySearchStrategy',
})
.toPromise();
let didCancel = false;
const asyncSearch = async () => {
abortCtrl.current = new AbortController();
setLoading(true);
const searchSubscription$ = data.search
.search<ActionDetailsRequestOptions, ActionDetailsStrategyResponse>(request, {
strategy: 'osquerySearchStrategy',
abortSignal: abortCtrl.current.signal,
})
.subscribe({
next: (response) => {
if (isCompleteResponse(response)) {
if (!didCancel) {
setLoading(false);
setActionDetailsResponse((prevResponse) => ({
...prevResponse,
actionDetails: response.actionDetails,
inspect: getInspectResponse(response, prevResponse.inspect),
}));
}
searchSubscription$.unsubscribe();
} else if (isErrorResponse(response)) {
if (!didCancel) {
setLoading(false);
}
// TODO: Make response error status clearer
notifications.toasts.addWarning(i18n.ERROR_ACTION_DETAILS);
searchSubscription$.unsubscribe();
}
},
error: (msg) => {
if (!(msg instanceof AbortError)) {
notifications.toasts.addDanger({
title: i18n.FAIL_ACTION_DETAILS,
text: msg.message,
});
}
},
});
};
abortCtrl.current.abort();
asyncSearch();
return () => {
didCancel = true;
abortCtrl.current.abort();
return {
...responseData,
inspect: getInspectResponse(responseData, {} as InspectResponse),
};
},
[data.search, notifications.toasts, skip]
{
enabled: !skip && !!actionDetailsRequest,
}
);
useEffect(() => {
@ -123,7 +64,6 @@ export const useActionDetails = ({
const myRequest = {
...(prevRequest ?? {}),
actionId,
docValueFields: docValueFields ?? [],
factoryQueryType: OsqueryQueries.actionDetails,
filterQuery: createFilter(filterQuery),
};
@ -132,11 +72,7 @@ export const useActionDetails = ({
}
return prevRequest;
});
}, [actionId, docValueFields, filterQuery]);
}, [actionId, filterQuery]);
useEffect(() => {
actionDetailsSearch(actionDetailsRequest);
}, [actionDetailsRequest, actionDetailsSearch]);
return [loading, actionDetailsResponse];
return response;
};

View file

@ -5,15 +5,15 @@
* 2.0.
*/
import { useEffect, useState } from 'react';
import { useQuery } from 'react-query';
import deepEqual from 'fast-deep-equal';
import { useCallback, useEffect, useRef, useState } from 'react';
import { createFilter } from '../common/helpers';
import { useKibana } from '../common/lib/kibana';
import {
ActionEdges,
PageInfoPaginated,
DocValueFields,
OsqueryQueries,
ActionsRequestOptions,
ActionsStrategyResponse,
@ -21,13 +21,8 @@ import {
} from '../../common/search_strategy';
import { ESTermQuery } from '../../common/typed_json';
import * as i18n from './translations';
import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common';
import { AbortError } from '../../../../../src/plugins/kibana_utils/common';
import { generateTablePaginationOptions, getInspectResponse, InspectResponse } from './helpers';
const ID = 'actionsAllQuery';
export interface ActionsArgs {
actions: ActionEdges;
id: string;
@ -42,7 +37,6 @@ interface UseAllActions {
direction: Direction;
limit: number;
sortField: string;
docValueFields?: DocValueFields[];
filterQuery?: ESTermQuery | string;
skip?: boolean;
}
@ -52,93 +46,39 @@ export const useAllActions = ({
direction,
limit,
sortField,
docValueFields,
filterQuery,
skip = false,
}: UseAllActions): [boolean, ActionsArgs] => {
const { data, notifications } = useKibana().services;
}: UseAllActions) => {
const { data } = useKibana().services;
const abortCtrl = useRef(new AbortController());
const [loading, setLoading] = useState(false);
const [actionsRequest, setHostRequest] = useState<ActionsRequestOptions | null>(null);
const [actionsResponse, setActionsResponse] = useState<ActionsArgs>({
actions: [],
id: ID,
inspect: {
dsl: [],
response: [],
},
isInspected: false,
pageInfo: {
activePage: 0,
fakeTotalCount: 0,
showMorePagesIndicator: false,
},
totalCount: -1,
});
const response = useQuery(
['actions', { activePage, direction, limit, sortField }],
async () => {
if (!actionsRequest) return Promise.resolve();
const actionsSearch = useCallback(
(request: ActionsRequestOptions | null) => {
if (request == null || skip) {
return;
}
const responseData = await data.search
.search<ActionsRequestOptions, ActionsStrategyResponse>(actionsRequest, {
strategy: 'osquerySearchStrategy',
})
.toPromise();
let didCancel = false;
const asyncSearch = async () => {
abortCtrl.current = new AbortController();
setLoading(true);
const searchSubscription$ = data.search
.search<ActionsRequestOptions, ActionsStrategyResponse>(request, {
strategy: 'osquerySearchStrategy',
abortSignal: abortCtrl.current.signal,
})
.subscribe({
next: (response) => {
if (isCompleteResponse(response)) {
if (!didCancel) {
setLoading(false);
setActionsResponse((prevResponse) => ({
...prevResponse,
actions: response.edges,
inspect: getInspectResponse(response, prevResponse.inspect),
pageInfo: response.pageInfo,
totalCount: response.totalCount,
}));
}
searchSubscription$.unsubscribe();
} else if (isErrorResponse(response)) {
if (!didCancel) {
setLoading(false);
}
// TODO: Make response error status clearer
notifications.toasts.addWarning(i18n.ERROR_ALL_ACTIONS);
searchSubscription$.unsubscribe();
}
},
error: (msg) => {
if (!(msg instanceof AbortError)) {
notifications.toasts.addDanger({ title: i18n.FAIL_ALL_ACTIONS, text: msg.message });
}
},
});
};
abortCtrl.current.abort();
asyncSearch();
return () => {
didCancel = true;
abortCtrl.current.abort();
return {
...responseData,
actions: responseData.edges,
inspect: getInspectResponse(responseData, {} as InspectResponse),
};
},
[data.search, notifications.toasts, skip]
{
enabled: !skip && !!actionsRequest,
}
);
useEffect(() => {
setHostRequest((prevRequest) => {
const myRequest = {
...(prevRequest ?? {}),
docValueFields: docValueFields ?? [],
factoryQueryType: OsqueryQueries.actions,
filterQuery: createFilter(filterQuery),
pagination: generateTablePaginationOptions(activePage, limit),
@ -152,11 +92,7 @@ export const useAllActions = ({
}
return prevRequest;
});
}, [activePage, direction, docValueFields, filterQuery, limit, sortField]);
}, [activePage, direction, filterQuery, limit, sortField]);
useEffect(() => {
actionsSearch(actionsRequest);
}, [actionsRequest, actionsSearch]);
return [loading, actionsResponse];
return response;
};

View file

@ -27,7 +27,7 @@ interface AgentsTableProps {
const AgentsTableComponent: React.FC<AgentsTableProps> = ({ selectedAgents, onChange }) => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(5);
const [sortField, setSortField] = useState<keyof Agent>('id');
const [sortField, setSortField] = useState<keyof Agent>('upgraded_at');
const [sortDirection, setSortDirection] = useState<Direction>(Direction.asc);
const [selectedItems, setSelectedItems] = useState([]);
const tableRef = useRef<EuiBasicTable<Agent>>(null);
@ -49,8 +49,11 @@ const AgentsTableComponent: React.FC<AgentsTableProps> = ({ selectedAgents, onCh
const onSelectionChange: EuiTableSelectionType<{}>['onSelectionChange'] = useCallback(
(newSelectedItems) => {
setSelectedItems(newSelectedItems);
// @ts-expect-error
onChange(newSelectedItems.map((item) => item._id));
if (onChange) {
// @ts-expect-error update types
onChange(newSelectedItems.map((item) => item._id));
}
},
[onChange]
);
@ -61,7 +64,7 @@ const AgentsTableComponent: React.FC<AgentsTableProps> = ({ selectedAgents, onCh
return <EuiHealth color={color}>{label}</EuiHealth>;
};
const [, { agents, totalCount }] = useAllAgents({
const { data = {} } = useAllAgents({
activePage: pageIndex,
limit: pageSize,
direction: sortDirection,
@ -96,10 +99,12 @@ const AgentsTableComponent: React.FC<AgentsTableProps> = ({ selectedAgents, onCh
() => ({
pageIndex,
pageSize,
totalItemCount: totalCount,
// @ts-expect-error update types
totalItemCount: data.totalCount ?? 0,
pageSizeOptions: [3, 5, 8],
}),
[pageIndex, pageSize, totalCount]
// @ts-expect-error update types
[pageIndex, pageSize, data.totalCount]
);
const sorting = useMemo(
@ -123,18 +128,26 @@ const AgentsTableComponent: React.FC<AgentsTableProps> = ({ selectedAgents, onCh
);
useEffect(() => {
if (selectedAgents?.length && agents.length && selectedItems.length !== selectedAgents.length) {
if (
selectedAgents?.length &&
// @ts-expect-error update types
data.agents?.length &&
selectedItems.length !== selectedAgents.length
) {
tableRef?.current?.setSelection(
// @ts-expect-error
selectedAgents.map((agentId) => find({ _id: agentId }, agents))
// @ts-expect-error update types
selectedAgents.map((agentId) => find({ _id: agentId }, data.agents))
);
}
}, [selectedAgents, agents, selectedItems.length]);
// @ts-expect-error update types
}, [selectedAgents, data.agents, selectedItems.length]);
return (
<EuiBasicTable<Agent>
ref={tableRef}
items={agents}
// @ts-expect-error update types
// eslint-disable-next-line react-perf/jsx-no-new-array-as-prop
items={data.agents ?? []}
itemId="_id"
columns={columns}
pagination={pagination}

View file

@ -30,9 +30,10 @@ export const generateTablePaginationOptions = (
export const getInspectResponse = <T extends FactoryQueryTypes>(
response: StrategyResponseType<T>,
prevResponse: InspectResponse
prevResponse?: InspectResponse
): InspectResponse => ({
dsl: response?.inspect?.dsl ?? prevResponse?.dsl ?? [],
// @ts-expect-error update types
response:
response != null ? [JSON.stringify(response.rawResponse, null, 2)] : prevResponse?.response,
});

View file

@ -6,13 +6,13 @@
*/
import deepEqual from 'fast-deep-equal';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery } from 'react-query';
import { createFilter } from '../common/helpers';
import { useKibana } from '../common/lib/kibana';
import {
PageInfoPaginated,
DocValueFields,
OsqueryQueries,
AgentsRequestOptions,
AgentsStrategyResponse,
@ -21,13 +21,8 @@ import {
import { ESTermQuery } from '../../common/typed_json';
import { Agent } from '../../common/shared_imports';
import * as i18n from './translations';
import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common';
import { AbortError } from '../../../../../src/plugins/kibana_utils/common';
import { generateTablePaginationOptions, getInspectResponse, InspectResponse } from './helpers';
const ID = 'agentsAllQuery';
export interface AgentsArgs {
agents: Agent[];
id: string;
@ -42,7 +37,6 @@ interface UseAllAgents {
direction: Direction;
limit: number;
sortField: string;
docValueFields?: DocValueFields[];
filterQuery?: ESTermQuery | string;
skip?: boolean;
}
@ -52,93 +46,39 @@ export const useAllAgents = ({
direction,
limit,
sortField,
docValueFields,
filterQuery,
skip = false,
}: UseAllAgents): [boolean, AgentsArgs] => {
const { data, notifications } = useKibana().services;
}: UseAllAgents) => {
const { data } = useKibana().services;
const abortCtrl = useRef(new AbortController());
const [loading, setLoading] = useState(false);
const [agentsRequest, setHostRequest] = useState<AgentsRequestOptions | null>(null);
const [agentsResponse, setAgentsResponse] = useState<AgentsArgs>({
agents: [],
id: ID,
inspect: {
dsl: [],
response: [],
},
isInspected: false,
pageInfo: {
activePage: 0,
fakeTotalCount: 0,
showMorePagesIndicator: false,
},
totalCount: -1,
});
const response = useQuery(
['agents', { activePage, direction, limit, sortField }],
async () => {
if (!agentsRequest) return Promise.resolve();
const agentsSearch = useCallback(
(request: AgentsRequestOptions | null) => {
if (request == null || skip) {
return;
}
const responseData = await data.search
.search<AgentsRequestOptions, AgentsStrategyResponse>(agentsRequest, {
strategy: 'osquerySearchStrategy',
})
.toPromise();
let didCancel = false;
const asyncSearch = async () => {
abortCtrl.current = new AbortController();
setLoading(true);
const searchSubscription$ = data.search
.search<AgentsRequestOptions, AgentsStrategyResponse>(request, {
strategy: 'osquerySearchStrategy',
abortSignal: abortCtrl.current.signal,
})
.subscribe({
next: (response) => {
if (isCompleteResponse(response)) {
if (!didCancel) {
setLoading(false);
setAgentsResponse((prevResponse) => ({
...prevResponse,
agents: response.edges,
inspect: getInspectResponse(response, prevResponse.inspect),
pageInfo: response.pageInfo,
totalCount: response.totalCount,
}));
}
searchSubscription$.unsubscribe();
} else if (isErrorResponse(response)) {
if (!didCancel) {
setLoading(false);
}
// TODO: Make response error status clearer
notifications.toasts.addWarning(i18n.ERROR_ALL_AGENTS);
searchSubscription$.unsubscribe();
}
},
error: (msg) => {
if (!(msg instanceof AbortError)) {
notifications.toasts.addDanger({ title: i18n.FAIL_ALL_AGENTS, text: msg.message });
}
},
});
};
abortCtrl.current.abort();
asyncSearch();
return () => {
didCancel = true;
abortCtrl.current.abort();
return {
...responseData,
agents: responseData.edges,
inspect: getInspectResponse(responseData),
};
},
[data.search, notifications.toasts, skip]
{
enabled: !skip && !!agentsRequest,
}
);
useEffect(() => {
setHostRequest((prevRequest) => {
const myRequest = {
...(prevRequest ?? {}),
docValueFields: docValueFields ?? [],
factoryQueryType: OsqueryQueries.agents,
filterQuery: createFilter(filterQuery),
pagination: generateTablePaginationOptions(activePage, limit),
@ -152,11 +92,7 @@ export const useAllAgents = ({
}
return prevRequest;
});
}, [activePage, direction, docValueFields, filterQuery, limit, sortField]);
}, [activePage, direction, filterQuery, limit, sortField]);
useEffect(() => {
agentsSearch(agentsRequest);
}, [agentsRequest, agentsSearch]);
return [loading, agentsResponse];
return response;
};

View file

@ -13,6 +13,8 @@ import ReactDOM from 'react-dom';
import { Router } from 'react-router-dom';
import { I18nProvider } from '@kbn/i18n/react';
import { ThemeProvider } from 'styled-components';
import { QueryClient, QueryClientProvider } from 'react-query';
import { ReactQueryDevtools } from 'react-query/devtools';
import { useUiSetting$ } from '../../../../src/plugins/kibana_react/public';
import { Storage } from '../../../../src/plugins/kibana_utils/public';
@ -22,6 +24,8 @@ import { OsqueryApp } from './components/app';
import { DEFAULT_DARK_MODE, PLUGIN_NAME } from '../common';
import { KibanaContextProvider } from './common/lib/kibana';
const queryClient = new QueryClient();
const OsqueryAppContext = () => {
const [darkMode] = useUiSetting$<boolean>(DEFAULT_DARK_MODE);
const theme = useMemo(
@ -51,6 +55,7 @@ export const renderApp = (
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
services={{
appName: PLUGIN_NAME,
kibanaVersion,
...core,
...services,
storage,
@ -59,7 +64,10 @@ export const renderApp = (
<EuiErrorBoundary>
<Router history={history}>
<I18nProvider>
<OsqueryAppContext />
<QueryClientProvider client={queryClient}>
<OsqueryAppContext />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</I18nProvider>
</Router>
</EuiErrorBoundary>

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { useHistory } from 'react-router-dom';
import {
KibanaContextProvider,
KibanaReactContextValue,
@ -12,6 +13,7 @@ import {
useUiSetting,
useUiSetting$,
withKibana,
reactRouterNavigate,
} from '../../../../../../../src/plugins/kibana_react/public';
import { StartServices } from '../../../types';
@ -22,8 +24,17 @@ export interface WithKibanaProps {
const useTypedKibana = () => useKibana<StartServices>();
const useRouterNavigate = (
to: Parameters<typeof reactRouterNavigate>[1],
onClickCallback?: Parameters<typeof reactRouterNavigate>[2]
) => {
const history = useHistory();
return reactRouterNavigate(history, to, onClickCallback);
};
export {
KibanaContextProvider,
useRouterNavigate,
useTypedKibana as useKibana,
useUiSetting,
useUiSetting$,

View file

@ -5,54 +5,45 @@
* 2.0.
*/
import React from 'react';
import React, { useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { Switch, Route } from 'react-router-dom';
import { EuiFlexGroup, EuiFlexItem, EuiTabs, EuiTab } from '@elastic/eui';
import { useLocation } from 'react-router-dom';
import {
EuiPage,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageHeader,
EuiTitle,
EuiSpacer,
} from '@elastic/eui';
import { PLUGIN_NAME } from '../../common';
import { LiveQuery } from '../live_query';
import { Container, Nav, Wrapper } from './layouts';
import { OsqueryAppRoutes } from '../routes';
import { useRouterNavigate } from '../common/lib/kibana';
export const OsqueryAppComponent = () => {
const location = useLocation();
const section = useMemo(() => location.pathname.split('/')[1] ?? 'overview', [location.pathname]);
return (
<EuiPage restrictWidth="1000px">
<EuiPageBody>
<EuiPageHeader>
<EuiTitle size="l">
<h1>
<FormattedMessage
id="xpack.osquery.helloWorldText"
defaultMessage="{name}"
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
values={{ name: PLUGIN_NAME }}
/>
</h1>
</EuiTitle>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentBody>
<EuiSpacer />
<Switch>
<Route path={`/live_query`}>
<LiveQuery />
</Route>
</Switch>
<EuiSpacer />
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
<Container>
<Wrapper>
<Nav>
<EuiFlexGroup gutterSize="l" alignItems="center">
<EuiFlexItem>
<EuiTabs display="condensed">
<EuiTab isSelected={section === 'overview'} {...useRouterNavigate('overview')}>
<FormattedMessage
id="xpack.osquery.appNavigation.overviewLinkText"
defaultMessage="Overview"
/>
</EuiTab>
<EuiTab isSelected={section === 'live_query'} {...useRouterNavigate('live_query')}>
<FormattedMessage
id="xpack.osquery.appNavigation.liveQueryLinkText"
defaultMessage="Live Query"
/>
</EuiTab>
</EuiTabs>
</EuiFlexItem>
</EuiFlexGroup>
</Nav>
<OsqueryAppRoutes />
</Wrapper>
</Container>
);
};

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import styled from 'styled-components';
export const Container = styled.div`
min-height: calc(
100vh - ${(props) => parseFloat(props.theme.eui.euiHeaderHeightCompensation) * 2}px
);
background: ${(props) => props.theme.eui.euiColorEmptyShade};
display: flex;
flex-direction: column;
`;
export const Wrapper = styled.div`
display: flex;
flex-direction: column;
flex: 1;
`;
export const Nav = styled.nav`
background: ${(props) => props.theme.eui.euiColorEmptyShade};
border-bottom: ${(props) => props.theme.eui.euiBorderThin};
padding: ${(props) =>
`${props.theme.eui.euiSize} ${props.theme.eui.euiSizeL} ${props.theme.eui.euiSize} ${props.theme.eui.euiSizeL}`};
.euiTabs {
padding-left: 3px;
margin-left: -3px;
}
`;

View file

@ -0,0 +1,94 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// copied from x-pack/plugins/fleet/public/applications/fleet/components/header.tsx
import React, { memo } from 'react';
import styled from 'styled-components';
import { EuiFlexGroup, EuiFlexItem, EuiTabs, EuiTab, EuiSpacer } from '@elastic/eui';
import { Props as EuiTabProps } from '@elastic/eui/src/components/tabs/tab';
import { EuiFlexItemProps } from '@elastic/eui/src/components/flex/flex_item';
const Container = styled.div`
border-bottom: ${(props) => props.theme.eui.euiBorderThin};
background-color: ${(props) => props.theme.eui.euiPageBackgroundColor};
`;
const Wrapper = styled.div<{ maxWidth?: number }>`
max-width: ${(props) => props.maxWidth || 1200}px;
margin-left: auto;
margin-right: auto;
padding-top: ${(props) => props.theme.eui.paddingSizes.xl};
padding-left: ${(props) => props.theme.eui.paddingSizes.m};
padding-right: ${(props) => props.theme.eui.paddingSizes.m};
`;
const Tabs = styled(EuiTabs)`
top: 1px;
&:before {
height: 0px;
}
`;
export interface HeaderProps {
maxWidth?: number;
leftColumn?: JSX.Element;
rightColumn?: JSX.Element;
rightColumnGrow?: EuiFlexItemProps['grow'];
tabs?: Array<Omit<EuiTabProps, 'name'> & { name?: JSX.Element | string }>;
tabsClassName?: string;
'data-test-subj'?: string;
}
const HeaderColumns: React.FC<Omit<HeaderProps, 'tabs'>> = memo(
({ leftColumn, rightColumn, rightColumnGrow }) => (
<EuiFlexGroup alignItems="center">
{leftColumn ? <EuiFlexItem>{leftColumn}</EuiFlexItem> : null}
{rightColumn ? <EuiFlexItem grow={rightColumnGrow}>{rightColumn}</EuiFlexItem> : null}
</EuiFlexGroup>
)
);
HeaderColumns.displayName = 'HeaderColumns';
export const Header: React.FC<HeaderProps> = ({
leftColumn,
rightColumn,
rightColumnGrow,
tabs,
maxWidth,
tabsClassName,
'data-test-subj': dataTestSubj,
}) => (
<Container data-test-subj={dataTestSubj}>
<Wrapper maxWidth={maxWidth}>
<HeaderColumns
leftColumn={leftColumn}
rightColumn={rightColumn}
rightColumnGrow={rightColumnGrow}
/>
<EuiFlexGroup>
{tabs ? (
<EuiFlexItem>
<EuiSpacer size="s" />
<Tabs className={tabsClassName}>
{tabs.map((props) => (
<EuiTab {...(props as EuiTabProps)} key={props.id}>
{props.name}
</EuiTab>
))}
</Tabs>
</EuiFlexItem>
) : (
<EuiFlexItem>
<EuiSpacer size="l" />
</EuiFlexItem>
)}
</EuiFlexGroup>
</Wrapper>
</Container>
);

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// copied from x-pack/plugins/fleet/public/applications/fleet/layouts/index.tsx
export { Container, Nav, Wrapper } from './default';
export { WithHeaderLayout, WithHeaderLayoutProps } from './with_header';
export { WithoutHeaderLayout } from './without_header';

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Fragment } from 'react';
import { EuiPageBody, EuiSpacer } from '@elastic/eui';
import { Header, HeaderProps } from './header';
import { Page, ContentWrapper } from './without_header';
export interface WithHeaderLayoutProps extends HeaderProps {
restrictWidth?: number;
restrictHeaderWidth?: number;
'data-test-subj'?: string;
children?: React.ReactNode;
}
export const WithHeaderLayout: React.FC<WithHeaderLayoutProps> = ({
restrictWidth,
restrictHeaderWidth,
children,
'data-test-subj': dataTestSubj,
...rest
}) => (
<Fragment>
<Header
maxWidth={restrictHeaderWidth}
data-test-subj={dataTestSubj ? `${dataTestSubj}_header` : undefined}
{...rest}
/>
<Page
restrictWidth={restrictWidth || 1200}
data-test-subj={dataTestSubj ? `${dataTestSubj}_page` : undefined}
>
<EuiPageBody>
<ContentWrapper>
<EuiSpacer size="m" />
{children}
</ContentWrapper>
</EuiPageBody>
</Page>
</Fragment>
);

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Fragment } from 'react';
import styled from 'styled-components';
import { EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui';
export const Page = styled(EuiPage)`
background: ${(props) => props.theme.eui.euiColorEmptyShade};
width: 100%;
align-self: center;
margin-left: 0;
margin-right: 0;
flex: 1;
`;
export const ContentWrapper = styled.div`
height: 100%;
`;
interface Props {
restrictWidth?: number;
children?: React.ReactNode;
}
export const WithoutHeaderLayout: React.FC<Props> = ({ restrictWidth, children }) => (
<Fragment>
<Page restrictWidth={restrictWidth || 1200}>
<EuiPageBody>
<ContentWrapper>
<EuiSpacer size="m" />
{children}
</ContentWrapper>
</EuiPageBody>
</Page>
</Fragment>
);

View file

@ -42,7 +42,8 @@ const OsqueryEditorComponent: React.FC<OsqueryEditorProps> = ({ defaultValue, on
name="osquery_editor"
setOptions={EDITOR_SET_OPTIONS}
editorProps={EDITOR_PROPS}
height="200px"
height="100px"
width="100%"
/>
);
};

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/* eslint-disable react/jsx-no-bind */
/* eslint-disable react-perf/jsx-no-new-function-as-prop */
import { produce } from 'immer';
import { EuiFlyout, EuiTitle, EuiFlyoutBody, EuiFlyoutHeader, EuiPortal } from '@elastic/eui';
import React from 'react';
import { AddPackQueryForm } from '../../packs/common/add_pack_query';
// @ts-expect-error update types
export const AddNewQueryFlyout = ({ data, handleChange, onClose }) => {
// @ts-expect-error update types
const handleSubmit = (payload) => {
// @ts-expect-error update types
const updatedPolicy = produce(data, (draft) => {
draft.inputs[0].streams.push({
data_stream: {
type: 'logs',
dataset: 'osquery_elastic_managed.osquery',
},
vars: {
query: {
type: 'text',
value: payload.query.attributes.query,
},
interval: {
type: 'text',
value: `${payload.interval}`,
},
id: {
type: 'text',
value: payload.query.id,
},
},
enabled: true,
});
});
onClose();
handleChange({
isValid: true,
updatedPolicy,
});
};
return (
<EuiPortal>
<EuiFlyout ownFocus onClose={onClose} aria-labelledby="flyoutTitle">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2 id="flyoutTitle">Attach next query</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<AddPackQueryForm handleSubmit={handleSubmit} />
</EuiFlyoutBody>
</EuiFlyout>
</EuiPortal>
);
};

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import qs from 'query-string';
import { Queries } from '../../queries';
import { Packs } from '../../packs';
import { LiveQuery } from '../../live_query';
const CustomTabTabsComponent = () => {
const location = useLocation();
const selectedTab = useMemo(() => qs.parse(location.search)?.tab, [location.search]);
if (selectedTab === 'packs') {
return <Packs />;
}
if (selectedTab === 'saved_queries') {
return <Queries />;
}
if (selectedTab === 'live_query') {
return <LiveQuery />;
}
return <Packs />;
};
export const CustomTabTabs = React.memo(CustomTabTabsComponent);

View file

@ -0,0 +1,240 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/* eslint-disable @typescript-eslint/naming-convention */
import produce from 'immer';
import { find } from 'lodash/fp';
import { EuiSpacer, EuiText, EuiHorizontalRule, EuiSuperSelect } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import deepEqual from 'fast-deep-equal';
import { useQuery } from 'react-query';
import {
// UseField,
useForm,
useFormData,
UseArray,
getUseField,
Field,
ToggleField,
Form,
} from '../../shared_imports';
// import { OsqueryStreamField } from '../../scheduled_query/common/osquery_stream_field';
import { useKibana } from '../../common/lib/kibana';
import { ScheduledQueryQueriesTable } from './scheduled_queries_table';
import { schema } from './schema';
const CommonUseField = getUseField({ component: Field });
const EDIT_SCHEDULED_QUERY_FORM_ID = 'editScheduledQueryForm';
interface EditScheduledQueryFormProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: Array<Record<string, any>>;
handleSubmit: () => Promise<void>;
}
const EditScheduledQueryFormComponent: React.FC<EditScheduledQueryFormProps> = ({
data,
handleSubmit,
}) => {
const { http } = useKibana().services;
const {
data: { saved_objects: packs } = {
saved_objects: [],
},
} = useQuery('packs', () => http.get('/internal/osquery/pack'));
const { form } = useForm({
id: EDIT_SCHEDULED_QUERY_FORM_ID,
onSubmit: handleSubmit,
schema,
defaultValue: data,
options: {
stripEmptyFields: false,
},
// @ts-expect-error update types
deserializer: (payload) => {
const deserialized = produce(payload, (draft) => {
// @ts-expect-error update types
draft.streams = draft.inputs[0].streams.map(({ data_stream, enabled, vars }) => ({
data: {
data_stream,
enabled,
vars,
},
}));
});
return deserialized;
},
// @ts-expect-error update types
serializer: (payload) => {
const serialized = produce(payload, (draft) => {
// @ts-expect-error update types
if (draft.inputs) {
// @ts-expect-error update types
draft.inputs[0].config = {
pack: {
type: 'id',
value: 'e33f5f30-705e-11eb-9e99-9f6b4d0d9506',
},
};
// @ts-expect-error update types
draft.inputs[0].type = 'osquery';
// @ts-expect-error update types
draft.inputs[0].streams = draft.inputs[0].streams?.map((stream) => stream.data) ?? [];
}
});
return serialized;
},
});
const { setFieldValue } = form;
const handlePackChange = useCallback(
(value) => {
const newPack = find(['id', value], packs);
setFieldValue(
'streams',
// @ts-expect-error update types
newPack.queries.map((packQuery, index) => ({
id: index,
isNew: true,
path: `streams[${index}]`,
data: {
data_stream: {
type: 'logs',
dataset: 'osquery_elastic_managed.osquery',
},
id: 'osquery-osquery_elastic_managed.osquery-7065c2dc-f835-4d13-9486-6eec515f39bd',
vars: {
query: {
type: 'text',
value: packQuery.query,
},
interval: {
type: 'text',
value: `${packQuery.interval}`,
},
id: {
type: 'text',
value: packQuery.id,
},
},
enabled: true,
},
}))
);
},
[packs, setFieldValue]
);
const [formData] = useFormData({ form, watch: ['streams'] });
const scheduledQueries = useMemo(() => {
if (formData.inputs) {
// @ts-expect-error update types
return formData.streams.reduce((acc, stream) => {
if (!stream.data) {
return acc;
}
return [...acc, stream.data];
}, []);
}
return [];
}, [formData]);
return (
<Form form={form}>
<EuiSuperSelect
// @ts-expect-error update types
options={packs.map((pack) => ({
value: pack.id,
inputDisplay: (
<>
<EuiText>{pack.name}</EuiText>
<EuiText size="s" color="subdued">
<p className="euiTextColor--subdued">{pack.description}</p>
</EuiText>
</>
),
}))}
valueOfSelected={packs[0]?.id}
onChange={handlePackChange}
/>
<ScheduledQueryQueriesTable data={scheduledQueries} />
<CommonUseField path="enabled" component={ToggleField} />
<EuiHorizontalRule />
<EuiSpacer />
<UseArray path="streams" readDefaultValueOnForm={true}>
{
// eslint-disable-next-line @typescript-eslint/no-unused-vars
({ items, form: streamsForm, addItem, removeItem }) => {
return (
<>
{/* {items.map((item) => {
return (
<UseField
key={item.path}
path={`${item.path}.data`}
component={OsqueryStreamField}
// eslint-disable-next-line react/jsx-no-bind, react-perf/jsx-no-new-function-as-prop
removeItem={() => removeItem(item.id)}
// readDefaultValueOnForm={true}
defaultValue={
item.isNew
? // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
{
data_stream: {
type: 'logs',
dataset: 'osquery_elastic_managed.osquery',
},
vars: {
query: {
type: 'text',
value: 'select * from uptime',
},
interval: {
type: 'text',
value: '120',
},
id: {
type: 'text',
value: uuid.v4(),
},
},
enabled: true,
}
: get(item.path, streamsForm.getFormData())
}
/>
);
})} */}
{/* <EuiButtonEmpty onClick={addItem} iconType="plusInCircleFilled">
{'Add query'}
</EuiButtonEmpty> */}
</>
);
}
}
</UseArray>
</Form>
);
};
export const EditScheduledQueryForm = React.memo(
EditScheduledQueryFormComponent,
(prevProps, nextProps) => deepEqual(prevProps.data, nextProps.data)
);

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useForm, Form, getUseField, Field, FIELD_TYPES } from '../../shared_imports';
const CommonUseField = getUseField({ component: Field });
const FORM_ID = 'inputStreamForm';
const schema = {
data_stream: {
dataset: {
type: FIELD_TYPES.TEXT,
},
type: {
type: FIELD_TYPES.TEXT,
},
},
enabled: {
type: FIELD_TYPES.TOGGLE,
label: 'Active',
},
id: {
type: FIELD_TYPES.TEXT,
},
vars: {
id: {
type: {
type: FIELD_TYPES.TEXT,
},
value: { type: FIELD_TYPES.TEXT },
},
interval: {
type: {
type: FIELD_TYPES.TEXT,
},
value: { type: FIELD_TYPES.TEXT },
},
query: {
type: {
type: FIELD_TYPES.TEXT,
},
value: { type: FIELD_TYPES.TEXT },
},
},
};
// @ts-expect-error update types
const InputStreamFormComponent = ({ data }) => {
const { form } = useForm({
id: FORM_ID,
schema,
defaultValue: data,
});
return (
<Form form={form}>
<CommonUseField path="vars.query.value" />
</Form>
);
};
export const InputStreamForm = React.memo(InputStreamFormComponent);

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/* eslint-disable react-perf/jsx-no-new-object-as-prop */
/* eslint-disable react-perf/jsx-no-new-array-as-prop */
import React, { useCallback } from 'react';
import produce from 'immer';
import { EuiRadioGroup } from '@elastic/eui';
// @ts-expect-error update types
export const ScheduledQueryInputType = ({ data, handleChange }) => {
const radios = [
{
id: 'pack',
label: 'Pack',
},
{
id: 'saved_queries',
label: 'Saved queries',
},
];
const onChange = useCallback(
(optionId: string) => {
// @ts-expect-error update types
const updatedPolicy = produce(data, (draft) => {
if (!draft.inputs[0].config) {
draft.inputs[0].config = {
input_source: {
type: 'text',
value: optionId,
},
};
} else {
draft.inputs[0].config.input_source.value = optionId;
}
});
handleChange({
isValid: true,
updatedPolicy,
});
},
[data, handleChange]
);
return (
<EuiRadioGroup
options={radios}
idSelected={data.inputs[0].config?.input_source?.value ?? 'saved_queries'}
onChange={onChange}
name="radio group"
legend={{
children: <span>{'Choose input type'}</span>,
}}
/>
);
};

View file

@ -0,0 +1,92 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { snakeCase } from 'lodash/fp';
import { EuiIcon, EuiSideNav } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import qs from 'query-string';
export const Navigation = () => {
const { push } = useHistory();
const location = useLocation();
const selectedItemName = useMemo(() => qs.parse(location.search)?.tab, [location.search]);
const handleTabClick = useCallback(
(tab) => {
push({
search: qs.stringify({ tab }),
});
},
[push]
);
const createItem = useCallback(
(name, data = {}) => ({
...data,
id: snakeCase(name),
name,
isSelected: selectedItemName === name,
onClick: () => handleTabClick(snakeCase(name)),
}),
[handleTabClick, selectedItemName]
);
const sideNav = useMemo(
() => [
createItem('Packs', {
forceOpen: true,
items: [
createItem('List', {
icon: <EuiIcon type="list" />,
}),
createItem('New pack', {
icon: <EuiIcon type="listAdd" />,
}),
],
}),
createItem('Saved Queries', {
forceOpen: true,
items: [
createItem('List', {
icon: <EuiIcon type="list" />,
}),
createItem('New query', {
icon: <EuiIcon type="listAdd" />,
}),
],
}),
// createItem('Scheduled Queries', {
// forceOpen: true,
// items: [
// createItem('List', {
// icon: <EuiIcon type="list" />,
// }),
// createItem('Schedule new query', {
// icon: <EuiIcon type="listAdd" />,
// }),
// ],
// }),
createItem('Live Query', {
forceOpen: true,
items: [
createItem('Run', {
icon: <EuiIcon type="play" />,
}),
createItem('History', {
icon: <EuiIcon type="tableDensityNormal" />,
}),
],
}),
],
[createItem]
);
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
return <EuiSideNav items={sideNav} style={{ width: 200 }} />;
};

View file

@ -0,0 +1,87 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/* eslint-disable react/jsx-no-bind */
/* eslint-disable react-perf/jsx-no-new-function-as-prop */
import { find } from 'lodash/fp';
import { produce } from 'immer';
import { EuiText, EuiSuperSelect } from '@elastic/eui';
import React from 'react';
import { useQuery } from 'react-query';
import { useKibana } from '../../common/lib/kibana';
// @ts-expect-error update types
export const ScheduledQueryPackSelector = ({ data, handleChange }) => {
const { http } = useKibana().services;
const {
data: { saved_objects: packs } = {
saved_objects: [],
},
} = useQuery('packs', () => http.get('/internal/osquery/pack'));
// @ts-expect-error update types
const handlePackChange = (value) => {
const newPack = find(['id', value], packs);
// @ts-expect-error update types
const updatedPolicy = produce(data, (draft) => {
draft.inputs[0].config.pack = {
type: 'text',
value: newPack.id,
};
// @ts-expect-error update types
draft.inputs[0].streams = newPack.queries.map((packQuery) => ({
data_stream: {
type: 'logs',
dataset: 'osquery_elastic_managed.osquery',
},
vars: {
query: {
type: 'text',
value: packQuery.query,
},
interval: {
type: 'text',
value: `${packQuery.interval}`,
},
id: {
type: 'text',
value: packQuery.id,
},
},
enabled: true,
}));
});
handleChange({
isValid: true,
updatedPolicy,
});
};
return (
<EuiSuperSelect
// @ts-expect-error update types
options={packs.map((pack) => ({
value: pack.id,
inputDisplay: (
<>
<EuiText>{pack.name}</EuiText>
<EuiText size="s" color="subdued">
<p className="euiTextColor--subdued">{pack.description}</p>
</EuiText>
</>
),
}))}
valueOfSelected={data.inputs[0].config}
onChange={handlePackChange}
/>
);
};

View file

@ -0,0 +1,142 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/* eslint-disable react-perf/jsx-no-new-function-as-prop */
/* eslint-disable react/jsx-no-bind */
/* eslint-disable react-perf/jsx-no-new-object-as-prop */
/* eslint-disable react/display-name */
/* eslint-disable react-perf/jsx-no-new-array-as-prop */
import React, { useState } from 'react';
import {
EuiBasicTable,
EuiButtonIcon,
EuiHealth,
EuiDescriptionList,
RIGHT_ALIGNMENT,
} from '@elastic/eui';
// @ts-expect-error update types
export const ScheduledQueryQueriesTable = ({ data }) => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(5);
const [sortField, setSortField] = useState('firstName');
const [sortDirection, setSortDirection] = useState('asc');
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState({});
const onTableChange = ({ page = {}, sort = {} }) => {
// @ts-expect-error update types
const { index, size } = page;
// @ts-expect-error update types
const { field, direction } = sort;
setPageIndex(index);
setPageSize(size);
setSortField(field);
setSortDirection(direction);
};
// @ts-expect-error update types
const toggleDetails = (item) => {
const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap };
// @ts-expect-error update types
if (itemIdToExpandedRowMapValues[item.id]) {
// @ts-expect-error update types
delete itemIdToExpandedRowMapValues[item.id];
} else {
const { online } = item;
const color = online ? 'success' : 'danger';
const label = online ? 'Online' : 'Offline';
const listItems = [
{
title: 'Online',
description: <EuiHealth color={color}>{label}</EuiHealth>,
},
];
// @ts-expect-error update types
itemIdToExpandedRowMapValues[item.id] = <EuiDescriptionList listItems={listItems} />;
}
setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues);
};
const columns = [
{
field: 'vars.id.value',
name: 'ID',
},
{
field: 'vars.interval.value',
name: 'Interval',
},
{
field: 'enabled',
name: 'Active',
},
{
name: 'Actions',
actions: [
{
name: 'Clone',
description: 'Clone this person',
type: 'icon',
icon: 'copy',
onClick: () => '',
},
],
},
{
align: RIGHT_ALIGNMENT,
width: '40px',
isExpander: true,
// @ts-expect-error update types
render: (item) => (
<EuiButtonIcon
onClick={() => toggleDetails(item)}
// @ts-expect-error update types
aria-label={itemIdToExpandedRowMap[item.id] ? 'Collapse' : 'Expand'}
// @ts-expect-error update types
iconType={itemIdToExpandedRowMap[item.id] ? 'arrowUp' : 'arrowDown'}
/>
),
},
];
const pagination = {
pageIndex,
pageSize,
totalItemCount: data.inputs[0].streams.length,
pageSizeOptions: [3, 5, 8],
};
const sorting = {
sort: {
field: sortField,
direction: sortDirection,
},
};
return (
<EuiBasicTable
items={data.inputs[0].streams}
itemId="id"
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
isExpandable={true}
hasActions={true}
// @ts-expect-error update types
columns={columns}
pagination={pagination}
// @ts-expect-error update types
sorting={sorting}
isSelectable={true}
onChange={onTableChange}
/>
);
};

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FIELD_TYPES } from '../../shared_imports';
export const schema = {
name: {
type: FIELD_TYPES.TEXT,
label: 'Name',
},
description: {
type: FIELD_TYPES.TEXT,
label: 'Description',
},
namespace: {
type: FIELD_TYPES.TEXT,
},
enabled: {
type: FIELD_TYPES.TOGGLE,
},
policy_id: {
type: FIELD_TYPES.TEXT,
},
streams: {
type: FIELD_TYPES.MULTI_SELECT,
vars: {
query: {
type: {
type: FIELD_TYPES.TEXT,
},
value: {
type: FIELD_TYPES.TEXT,
},
},
},
},
};

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './lazy_osquery_managed_empty_create_policy_extension';
export * from './lazy_osquery_managed_empty_edit_policy_extension';
export * from './lazy_osquery_managed_policy_create_extension';
export * from './lazy_osquery_managed_policy_edit_extension';
export * from './lazy_osquery_managed_custom_extension';

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { lazy } from 'react';
import { PackageCustomExtensionComponent } from '../../../fleet/public';
export const LazyOsqueryManagedCustomExtension = lazy<PackageCustomExtensionComponent>(async () => {
const { OsqueryManagedCustomExtension } = await import('./osquery_managed_custom_extension');
return {
default: OsqueryManagedCustomExtension,
};
});

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { lazy } from 'react';
import { PackagePolicyCreateExtensionComponent } from '../../../fleet/public';
export const LazyOsqueryManagedEmptyCreatePolicyExtension = lazy<PackagePolicyCreateExtensionComponent>(
async () => {
const { OsqueryManagedEmptyCreatePolicyExtension } = await import(
'./osquery_managed_empty_create_policy_extension'
);
return {
default: OsqueryManagedEmptyCreatePolicyExtension,
};
}
);

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { lazy } from 'react';
import { PackagePolicyEditExtensionComponent } from '../../../fleet/public';
export const LazyOsqueryManagedEmptyEditPolicyExtension = lazy<PackagePolicyEditExtensionComponent>(
async () => {
const { OsqueryManagedEmptyEditPolicyExtension } = await import(
'./osquery_managed_empty_edit_policy_extension'
);
return {
default: OsqueryManagedEmptyEditPolicyExtension,
};
}
);

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { lazy } from 'react';
import { PackagePolicyCreateExtensionComponent } from '../../../fleet/public';
export const LazyOsqueryManagedPolicyCreateExtension = lazy<PackagePolicyCreateExtensionComponent>(
async () => {
const { OsqueryManagedPolicyCreateExtension } = await import(
'./osquery_managed_policy_create_extension'
);
return {
default: OsqueryManagedPolicyCreateExtension,
};
}
);

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { lazy } from 'react';
import { PackagePolicyEditExtensionComponent } from '../../../fleet/public';
export const LazyOsqueryManagedPolicyEditExtension = lazy<PackagePolicyEditExtensionComponent>(
async () => {
const { OsqueryManagedPolicyCreateExtension } = await import(
'./osquery_managed_policy_create_extension'
);
return {
default: OsqueryManagedPolicyCreateExtension,
};
}
);

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { PackageCustomExtensionComponentProps } from '../../../fleet/public';
import { CustomTabTabs } from './components/custom_tab_tabs';
import { Navigation } from './components/navigation';
const queryClient = new QueryClient();
/**
* Exports Osquery-specific package policy instructions
* for use in the Fleet app custom tab
*/
export const OsqueryManagedCustomExtension = React.memo<PackageCustomExtensionComponentProps>(
() => (
<QueryClientProvider client={queryClient}>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<Navigation />
</EuiFlexItem>
<EuiFlexItem>
<CustomTabTabs />
</EuiFlexItem>
</EuiFlexGroup>
</QueryClientProvider>
)
);
OsqueryManagedCustomExtension.displayName = 'OsqueryManagedCustomExtension';

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect } from 'react';
import { produce } from 'immer';
import deepEqual from 'fast-deep-equal';
import { PackagePolicyCreateExtensionComponentProps } from '../../../fleet/public';
/**
* Exports Osquery-specific package policy instructions
* for use in the Fleet app create / edit package policy
*/
const OsqueryManagedEmptyCreatePolicyExtensionComponent: React.FC<PackagePolicyCreateExtensionComponentProps> = ({
onChange,
newPolicy,
}) => {
useEffect(() => {
const updatedPolicy = produce(newPolicy, (draft) => {
draft.inputs.forEach((input) => (input.streams = []));
});
onChange({
isValid: true,
updatedPolicy,
});
});
return <></>;
};
OsqueryManagedEmptyCreatePolicyExtensionComponent.displayName =
'OsqueryManagedEmptyCreatePolicyExtension';
export const OsqueryManagedEmptyCreatePolicyExtension = React.memo(
OsqueryManagedEmptyCreatePolicyExtensionComponent,
// we don't want to update the component if onChange has changed
(prevProps, nextProps) => deepEqual(prevProps.newPolicy, nextProps.newPolicy)
);

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { PackagePolicyEditExtensionComponentProps } from '../../../fleet/public';
/**
* Exports Osquery-specific package policy instructions
* for use in the Fleet app edit package policy
*/
const OsqueryManagedEmptyEditPolicyExtensionComponent = () => <></>;
OsqueryManagedEmptyEditPolicyExtensionComponent.displayName =
'OsqueryManagedEmptyEditPolicyExtension';
export const OsqueryManagedEmptyEditPolicyExtension = React.memo<PackagePolicyEditExtensionComponentProps>(
OsqueryManagedEmptyEditPolicyExtensionComponent
);

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButton } from '@elastic/eui';
import React, { useCallback, useState } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { PackagePolicyCreateExtensionComponentProps } from '../../../fleet/public';
import { ScheduledQueryInputType } from './components/input_type';
import { ScheduledQueryPackSelector } from './components/pack_selector';
import { ScheduledQueryQueriesTable } from './components/scheduled_queries_table';
import { AddNewQueryFlyout } from './components/add_new_query_flyout';
const queryClient = new QueryClient();
/**
* Exports Osquery-specific package policy instructions
* for use in the Fleet app create / edit package policy
*/
export const OsqueryManagedPolicyCreateExtension = React.memo<PackagePolicyCreateExtensionComponentProps>(
({ onChange, newPolicy }) => {
const [showAddQueryFlyout, setShowAddQueryFlyout] = useState(false);
const handleShowFlyout = useCallback(() => setShowAddQueryFlyout(true), []);
const handleHideFlyout = useCallback(() => setShowAddQueryFlyout(false), []);
return (
<QueryClientProvider client={queryClient}>
<ScheduledQueryInputType data={newPolicy} handleChange={onChange} />
{newPolicy.inputs[0].config?.input_source?.value === 'pack' && (
<ScheduledQueryPackSelector data={newPolicy} handleChange={onChange} />
)}
{newPolicy.inputs[0].streams.length && (
// @ts-expect-error update types
<ScheduledQueryQueriesTable data={newPolicy} handleChange={onChange} />
)}
{newPolicy.inputs[0].config?.input_source?.value !== 'pack' && (
<EuiButton fill onClick={handleShowFlyout}>
{'Attach next query'}
</EuiButton>
)}
{showAddQueryFlyout && (
<AddNewQueryFlyout data={newPolicy} handleChange={onChange} onClose={handleHideFlyout} />
)}
</QueryClientProvider>
);
}
);
OsqueryManagedPolicyCreateExtension.displayName = 'OsqueryManagedPolicyCreateExtension';

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiCodeBlock, EuiSpacer } from '@elastic/eui';
import React from 'react';
import { useParams } from 'react-router-dom';
import { useActionDetails } from '../../actions/use_action_details';
import { ResultsTable } from '../../results/results_table';
const QueryAgentResultsComponent = () => {
const { actionId, agentId } = useParams<{ actionId: string; agentId: string }>();
const { data } = useActionDetails({ actionId });
return (
<>
<EuiCodeBlock language="sql" fontSize="m" paddingSize="m">
{
// @ts-expect-error update types
data?.actionDetails._source?.data?.query
}
</EuiCodeBlock>
<EuiSpacer />
<ResultsTable actionId={actionId} agentId={agentId} />
</>
);
};
export const QueryAgentResults = React.memo(QueryAgentResultsComponent);

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isEmpty } from 'lodash/fp';
import { EuiSpacer } from '@elastic/eui';
import React, { useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useActionDetails } from '../../actions/use_action_details';
import { ResultTabs } from './tabs';
import { LiveQueryForm } from '../form';
const EditLiveQueryPageComponent = () => {
const { actionId } = useParams<{ actionId: string }>();
const [loading, { actionDetails }] = useActionDetails({ actionId });
const handleSubmit = useCallback(() => Promise.resolve(), []);
if (loading) {
return <>{'Loading...'}</>;
}
return (
<>
{!isEmpty(actionDetails) && (
<LiveQueryForm actionDetails={actionDetails} onSubmit={handleSubmit} />
)}
<EuiSpacer />
<ResultTabs />
</>
);
};
export const EditLiveQueryPage = React.memo(EditLiveQueryPageComponent);

View file

@ -6,40 +6,34 @@
*/
import { EuiButton, EuiSpacer } from '@elastic/eui';
import React, { useCallback } from 'react';
import React from 'react';
import { UseField, Form, useForm } from '../../shared_imports';
import { AgentsTableField } from './agents_table_field';
import { CodeEditorField } from './code_editor_field';
import { LiveQueryQueryField } from './live_query_query_field';
const FORM_ID = 'liveQueryForm';
interface LiveQueryFormProps {
actionDetails?: Record<string, string>;
defaultValue?: unknown;
onSubmit: (payload: Record<string, string>) => Promise<void>;
}
const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({ actionDetails, onSubmit }) => {
const handleSubmit = useCallback(
(payload) => {
onSubmit(payload);
return Promise.resolve();
},
[onSubmit]
);
const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({ defaultValue, onSubmit }) => {
const { form } = useForm({
id: FORM_ID,
// schema: formSchema,
onSubmit: handleSubmit,
onSubmit,
options: {
stripEmptyFields: false,
},
defaultValue: actionDetails,
deserializer: ({ fields, _source }) => ({
agents: fields?.agents,
command: _source?.data?.commands[0],
}),
defaultValue: {
// @ts-expect-error update types
query: defaultValue ?? {
id: null,
query: '',
},
},
});
const { submit } = form;
@ -48,8 +42,9 @@ const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({ actionDetails, o
<Form form={form}>
<UseField path="agents" component={AgentsTableField} />
<EuiSpacer />
<UseField path="command" component={CodeEditorField} />
<EuiButton onClick={submit}>Send query</EuiButton>
<UseField path="query" component={LiveQueryQueryField} />
<EuiSpacer />
<EuiButton onClick={submit}>{'Send query'}</EuiButton>
</Form>
);
};

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// import { find } from 'lodash/fp';
// import { EuiCodeBlock, EuiSuperSelect, EuiText, EuiSpacer } from '@elastic/eui';
import React, { useCallback } from 'react';
// import { useQuery } from 'react-query';
import { FieldHook } from '../../shared_imports';
// import { useKibana } from '../../common/lib/kibana';
import { OsqueryEditor } from '../../editor';
interface LiveQueryQueryFieldProps {
field: FieldHook<{
id: string | null;
query: string;
}>;
}
const LiveQueryQueryFieldComponent: React.FC<LiveQueryQueryFieldProps> = ({ field }) => {
// const { http } = useKibana().services;
// const { data } = useQuery('savedQueryList', () =>
// http.get('/internal/osquery/saved_query', {
// query: {
// pageIndex: 0,
// pageSize: 100,
// sortField: 'updated_at',
// sortDirection: 'desc',
// },
// })
// );
// const queryOptions =
// // @ts-expect-error update types
// data?.saved_objects.map((savedQuery) => ({
// value: savedQuery,
// inputDisplay: savedQuery.attributes.name,
// dropdownDisplay: (
// <>
// <strong>{savedQuery.attributes.name}</strong>
// <EuiText size="s" color="subdued">
// <p className="euiTextColor--subdued">{savedQuery.attributes.description}</p>
// </EuiText>
// <EuiCodeBlock language="sql" fontSize="s" paddingSize="s">
// {savedQuery.attributes.query}
// </EuiCodeBlock>
// </>
// ),
// })) ?? [];
const { value, setValue } = field;
// const handleSavedQueryChange = useCallback(
// (newValue) => {
// setValue({
// id: newValue.id,
// query: newValue.attributes.query,
// });
// },
// [setValue]
// );
const handleEditorChange = useCallback(
(newValue) => {
setValue({
id: null,
query: newValue,
});
},
[setValue]
);
return (
<>
{/* <EuiSuperSelect
valueOfSelected={find(['id', value.id], data?.saved_objects)}
options={queryOptions}
onChange={handleSavedQueryChange}
/>
<EuiSpacer /> */}
<OsqueryEditor defaultValue={value.query} onChange={handleEditorChange} />
</>
);
};
export const LiveQueryQueryField = React.memo(LiveQueryQueryFieldComponent);

View file

@ -11,7 +11,7 @@ export const formSchema: FormSchema = {
agents: {
type: FIELD_TYPES.MULTI_SELECT,
},
command: {
query: {
type: FIELD_TYPES.TEXTAREA,
validations: [],
},

View file

@ -5,28 +5,42 @@
* 2.0.
*/
import { EuiSpacer } from '@elastic/eui';
import React from 'react';
import { Switch, Route, useRouteMatch } from 'react-router-dom';
import { useMutation } from 'react-query';
import { useLocation } from 'react-router-dom';
import { QueriesPage } from './queries';
import { NewLiveQueryPage } from './new';
import { EditLiveQueryPage } from './edit';
import { useKibana } from '../common/lib/kibana';
import { LiveQueryForm } from './form';
import { ResultTabs } from '../queries/edit/tabs';
const LiveQueryComponent = () => {
const match = useRouteMatch();
const location = useLocation();
const { http } = useKibana().services;
const createActionMutation = useMutation((payload: Record<string, unknown>) =>
http.post('/internal/osquery/action', {
body: JSON.stringify(payload),
})
);
return (
<Switch>
<Route path={`${match.url}/queries/new`}>
<NewLiveQueryPage />
</Route>
<Route path={`${match.url}/queries/:actionId`}>
<EditLiveQueryPage />
</Route>
<Route path={`${match.url}/queries`}>
<QueriesPage />
</Route>
</Switch>
<>
{
<LiveQueryForm
defaultValue={location.state?.query}
// @ts-expect-error update types
onSubmit={createActionMutation.mutate}
/>
}
{createActionMutation.data && (
<>
<EuiSpacer />
<ResultTabs actionId={createActionMutation.data?.action.action_id} />
</>
)}
</>
);
};

View file

@ -1,30 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { useKibana } from '../../common/lib/kibana';
import { LiveQueryForm } from '../form';
const NewLiveQueryPageComponent = () => {
const { http } = useKibana().services;
const history = useHistory();
const handleSubmit = useCallback(
async (props) => {
const response = await http.post('/api/osquery/queries', { body: JSON.stringify(props) });
const requestParamsActionId = JSON.parse(response.meta.request.params.body).action_id;
history.push(`/live_query/queries/${requestParamsActionId}`);
},
[history, http]
);
return <LiveQueryForm onSubmit={handleSubmit} />;
};
export const NewLiveQueryPage = React.memo(NewLiveQueryPageComponent);

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiSpacer, EuiTitle } from '@elastic/eui';
import React from 'react';
import { ActionsTable } from '../../actions/actions_table';
const QueriesPageComponent = () => {
return (
<>
<EuiTitle>
<h1>{'Queries'}</h1>
</EuiTitle>
<EuiSpacer />
<ActionsTable />
</>
);
};
export const QueriesPage = React.memo(QueriesPageComponent);

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/* eslint-disable react-perf/jsx-no-new-function-as-prop, react/jsx-no-bind */
import React, { Fragment } from 'react';
import { EuiTextArea } from '@elastic/eui';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ActionParamsProps } from '../../../triggers_actions_ui/public/types';
interface ExampleActionParams {
message: string;
}
const ExampleParamsFields: React.FunctionComponent<ActionParamsProps<ExampleActionParams>> = ({
actionParams,
editAction,
index,
errors,
}) => {
// console.error('actionParams', actionParams, index, errors);
const { message } = actionParams;
return (
<Fragment>
<EuiTextArea
fullWidth
isInvalid={errors.message.length > 0 && message !== undefined}
name="message"
value={message || ''}
onChange={(e) => {
editAction('message', e.target.value, index);
}}
onBlur={() => {
if (!message) {
editAction('message', '', index);
}
}}
/>
</Fragment>
);
};
// Export as default in order to support lazy loading
// eslint-disable-next-line import/no-default-export
export { ExampleParamsFields as default };

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { lazy } from 'react';
import { i18n } from '@kbn/i18n';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ActionTypeModel, ValidationResult } from '../../../triggers_actions_ui/public/types';
interface ExampleActionParams {
message: string;
}
export function getActionType(): ActionTypeModel {
return {
id: '.osquery',
iconClass: 'logoOsquery',
selectMessage: i18n.translate(
'xpack.osquery.components.builtinActionTypes.exampleAction.selectMessageText',
{
defaultMessage: 'Example Action is used to show how to create new action type UI.',
}
),
actionTypeTitle: i18n.translate(
'xpack.osquery.components.builtinActionTypes.exampleAction.actionTypeTitle',
{
defaultMessage: 'Example Action',
}
),
// @ts-expect-error update types
validateConnector: (action): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
someConnectorField: new Array<string>(),
};
validationResult.errors = errors;
if (!action.config.someConnectorField) {
errors.someConnectorField.push(
i18n.translate(
'xpack.osquery.components.builtinActionTypes.error.requiredSomeConnectorFieldeText',
{
defaultMessage: 'SomeConnectorField is required.',
}
)
);
}
return validationResult;
},
validateParams: (actionParams: ExampleActionParams): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
message: new Array<string>(),
};
validationResult.errors = errors;
if (!actionParams.message?.length) {
errors.message.push(
i18n.translate(
'xpack.osquery.components.builtinActionTypes.error.requiredExampleMessageText',
{
defaultMessage: 'Message is required.',
}
)
);
}
return validationResult;
},
actionConnectorFields: null,
actionParamsFields: lazy(() => import('./example_params_fields')),
};
}

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui';
import React from 'react';
import { SavedQueryForm } from '../../queries/form';
// @ts-expect-error update types
const AddNewPackQueryFlyoutComponent = ({ handleClose, handleSubmit }) => (
<EuiFlyout ownFocus onClose={handleClose} aria-labelledby="flyoutTitle">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2 id="flyoutTitle">{'Add new Saved Query'}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<SavedQueryForm handleSubmit={handleSubmit} />
</EuiFlyoutBody>
</EuiFlyout>
);
export const AddNewPackQueryFlyout = React.memo(AddNewPackQueryFlyoutComponent);

View file

@ -0,0 +1,127 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/* eslint-disable react-perf/jsx-no-new-object-as-prop */
import { EuiButton, EuiCodeBlock, EuiSpacer, EuiText, EuiLink, EuiPortal } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { getUseField, useForm, Field, Form, FIELD_TYPES } from '../../shared_imports';
import { useKibana } from '../../common/lib/kibana';
import { AddNewPackQueryFlyout } from './add_new_pack_query_flyout';
const CommonUseField = getUseField({ component: Field });
// @ts-expect-error update types
const AddPackQueryFormComponent = ({ handleSubmit }) => {
const queryClient = useQueryClient();
const [showAddQueryFlyout, setShowAddQueryFlyout] = useState(false);
const { http } = useKibana().services;
const { data } = useQuery('savedQueryList', () =>
http.get('/internal/osquery/saved_query', {
query: {
pageIndex: 0,
pageSize: 100,
sortField: 'updated_at',
sortDirection: 'desc',
},
})
);
const { form } = useForm({
id: 'addPackQueryForm',
onSubmit: handleSubmit,
defaultValue: {
query: {},
},
schema: {
query: {
type: FIELD_TYPES.SUPER_SELECT,
label: 'Pick from Saved Queries',
},
interval: {
type: FIELD_TYPES.NUMBER,
label: 'Interval in seconds',
},
},
});
const { submit } = form;
const createSavedQueryMutation = useMutation(
(payload) => http.post(`/internal/osquery/saved_query`, { body: JSON.stringify(payload) }),
{
onSuccess: () => {
queryClient.invalidateQueries('savedQueryList');
setShowAddQueryFlyout(false);
},
}
);
const queryOptions = useMemo(
() =>
// @ts-expect-error update types
data?.saved_objects.map((savedQuery) => ({
value: {
id: savedQuery.id,
attributes: savedQuery.attributes,
type: savedQuery.type,
},
inputDisplay: savedQuery.attributes.name,
dropdownDisplay: (
<>
<strong>{savedQuery.attributes.name}</strong>
<EuiText size="s" color="subdued">
<p className="euiTextColor--subdued">{savedQuery.attributes.description}</p>
</EuiText>
<EuiCodeBlock language="sql" fontSize="m" paddingSize="m">
{savedQuery.attributes.query}
</EuiCodeBlock>
</>
),
})) ?? [],
[data?.saved_objects]
);
const handleShowFlyout = useCallback(() => setShowAddQueryFlyout(true), []);
const handleCloseFlyout = useCallback(() => setShowAddQueryFlyout(false), []);
return (
<>
<Form form={form}>
<CommonUseField
path="query"
labelAppend={
<EuiText size="xs">
<EuiLink onClick={handleShowFlyout}>{'Add new saved query'}</EuiLink>
</EuiText>
}
euiFieldProps={{
options: queryOptions,
}}
/>
<EuiSpacer />
<CommonUseField path="interval" />
<EuiSpacer />
<EuiButton fill onClick={submit}>
{'Add query'}
</EuiButton>
</Form>
{showAddQueryFlyout && (
<EuiPortal>
<AddNewPackQueryFlyout
handleClose={handleCloseFlyout}
handleSubmit={createSavedQueryMutation.mutate}
/>
</EuiPortal>
)}
</>
);
};
export const AddPackQueryForm = React.memo(AddPackQueryFormComponent);

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButton, EuiSpacer } from '@elastic/eui';
import React from 'react';
import { getUseField, useForm, Field, Form, FIELD_TYPES } from '../../shared_imports';
import { PackQueriesField } from './pack_queries_field';
const CommonUseField = getUseField({ component: Field });
// @ts-expect-error update types
const PackFormComponent = ({ data, handleSubmit }) => {
const { form } = useForm({
id: 'addPackForm',
onSubmit: (payload) => {
return handleSubmit(payload);
},
defaultValue: data ?? {
name: '',
description: '',
queries: [],
},
schema: {
name: {
type: FIELD_TYPES.TEXT,
label: 'Pack name',
},
description: {
type: FIELD_TYPES.TEXTAREA,
label: 'Description',
},
queries: {
type: FIELD_TYPES.MULTI_SELECT,
label: 'Queries',
},
},
});
const { submit } = form;
return (
<Form form={form}>
<CommonUseField path="name" />
<EuiSpacer />
<CommonUseField path="description" />
<EuiSpacer />
<CommonUseField path="queries" component={PackQueriesField} />
<EuiSpacer />
<EuiButton fill onClick={submit}>
{'Save pack'}
</EuiButton>
</Form>
);
};
export const PackForm = React.memo(PackFormComponent);

View file

@ -0,0 +1,76 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { reject } from 'lodash/fp';
import { produce } from 'immer';
import { EuiSpacer } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { useQueries } from 'react-query';
import { useKibana } from '../../common/lib/kibana';
import { PackQueriesTable } from '../common/pack_queries_table';
import { AddPackQueryForm } from '../common/add_pack_query';
// @ts-expect-error update types
const PackQueriesFieldComponent = ({ field }) => {
const { value, setValue } = field;
const { http } = useKibana().services;
const packQueriesData = useQueries(
// @ts-expect-error update types
value.map((query) => ({
queryKey: ['savedQuery', { id: query.id }],
queryFn: () => http.get(`/internal/osquery/saved_query/${query.id}`),
})) ?? []
);
const packQueries = useMemo(
() =>
// @ts-expect-error update types
packQueriesData.reduce((acc, packQueryData) => {
if (packQueryData.data) {
return [...acc, packQueryData.data];
}
return acc;
}, []) ?? [],
[packQueriesData]
);
const handleAddQuery = useCallback(
(newQuery) =>
setValue(
produce((draft) => {
draft.push({
interval: newQuery.interval,
query: newQuery.query.attributes.query,
id: newQuery.query.id,
name: newQuery.query.attributes.name,
});
})
),
[setValue]
);
const handleRemoveQuery = useCallback(
(query) => setValue(produce((draft) => reject(['id', query.id], draft))),
[setValue]
);
return (
<>
<PackQueriesTable
items={packQueries}
config={field.value}
handleRemoveQuery={handleRemoveQuery}
/>
<EuiSpacer />
<AddPackQueryForm handleSubmit={handleAddQuery} />
</>
);
};
export const PackQueriesField = React.memo(PackQueriesFieldComponent);

View file

@ -0,0 +1,140 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/* eslint-disable @typescript-eslint/no-shadow, react-perf/jsx-no-new-object-as-prop, react/jsx-no-bind, react/display-name, react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-array-as-prop */
import { find } from 'lodash/fp';
import React, { useState } from 'react';
import {
EuiBasicTable,
EuiButtonIcon,
EuiHealth,
EuiDescriptionList,
RIGHT_ALIGNMENT,
} from '@elastic/eui';
// @ts-expect-error update types
const PackQueriesTableComponent = ({ items, config, handleRemoveQuery }) => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [sortField, setSortField] = useState('firstName');
const [sortDirection, setSortDirection] = useState('asc');
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState({});
const totalItemCount = 100;
const onTableChange = ({ page = {}, sort = {} }) => {
// @ts-expect-error update types
const { index: pageIndex, size: pageSize } = page;
// @ts-expect-error update types
const { field: sortField, direction: sortDirection } = sort;
setPageIndex(pageIndex);
setPageSize(pageSize);
setSortField(sortField);
setSortDirection(sortDirection);
};
// @ts-expect-error update types
const toggleDetails = (item) => {
const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap };
// @ts-expect-error update types
if (itemIdToExpandedRowMapValues[item.id]) {
// @ts-expect-error update types
delete itemIdToExpandedRowMapValues[item.id];
} else {
const { online } = item;
const color = online ? 'success' : 'danger';
const label = online ? 'Online' : 'Offline';
const listItems = [
{
title: 'Nationality',
description: `aa`,
},
{
title: 'Online',
description: <EuiHealth color={color}>{label}</EuiHealth>,
},
];
// @ts-expect-error update types
itemIdToExpandedRowMapValues[item.id] = <EuiDescriptionList listItems={listItems} />;
}
setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues);
};
const columns = [
{
field: 'name',
name: 'Query Name',
},
{
name: 'Interval',
// @ts-expect-error update types
render: (query) => find(['name', query.name], config).interval,
},
{
name: 'Actions',
actions: [
{
name: 'Remove',
description: 'Remove this query',
type: 'icon',
icon: 'trash',
onClick: handleRemoveQuery,
},
],
},
{
align: RIGHT_ALIGNMENT,
width: '40px',
isExpander: true,
// @ts-expect-error update types
render: (item) => (
<EuiButtonIcon
onClick={() => toggleDetails(item)}
// @ts-expect-error update types
aria-label={itemIdToExpandedRowMap[item.id] ? 'Collapse' : 'Expand'}
// @ts-expect-error update types
iconType={itemIdToExpandedRowMap[item.id] ? 'arrowUp' : 'arrowDown'}
/>
),
},
];
const pagination = {
pageIndex,
pageSize,
totalItemCount,
pageSizeOptions: [3, 5, 8],
};
const sorting = {
sort: {
field: sortField,
direction: sortDirection,
},
};
return (
<EuiBasicTable
items={items}
itemId="id"
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
isExpandable={true}
hasActions={true}
// @ts-expect-error update types
columns={columns}
pagination={pagination}
// @ts-expect-error update types
sorting={sorting}
isSelectable={true}
onChange={onTableChange}
/>
);
};
export const PackQueriesTable = React.memo(PackQueriesTableComponent);

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/* eslint-disable react-perf/jsx-no-new-object-as-prop */
import React from 'react';
import { useMutation, useQuery } from 'react-query';
import { PackForm } from '../common/pack_form';
import { useKibana } from '../../common/lib/kibana';
interface EditPackPageProps {
onSuccess: () => void;
packId: string;
}
const EditPackPageComponent: React.FC<EditPackPageProps> = ({ onSuccess, packId }) => {
const { http } = useKibana().services;
const {
data = {
queries: [],
},
} = useQuery(['pack', { id: packId }], ({ queryKey }) => {
return http.get(`/internal/osquery/pack/${queryKey[1].id}`);
});
const updatePackMutation = useMutation(
(payload) =>
http.put(`/internal/osquery/pack/${packId}`, {
body: JSON.stringify({
...data,
// @ts-expect-error update types
...payload,
}),
}),
{
onSuccess,
}
);
if (!data.id) {
return <>{'Loading...'}</>;
}
return <PackForm data={data} handleSubmit={updatePackMutation.mutate} />;
};
export const EditPackPage = React.memo(EditPackPageComponent);

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useState } from 'react';
import { PacksPage } from './list';
import { NewPackPage } from './new';
import { EditPackPage } from './edit';
const PacksComponent = () => {
const [showNewPackForm, setShowNewPackForm] = useState(false);
const [editPackId, setEditPackId] = useState<string | null>(null);
const goBack = useCallback(() => {
setShowNewPackForm(false);
setEditPackId(null);
}, []);
const handleNewQueryClick = useCallback(() => setShowNewPackForm(true), []);
if (showNewPackForm) {
return <NewPackPage onSuccess={goBack} />;
}
if (editPackId?.length) {
return <EditPackPage onSuccess={goBack} packId={editPackId} />;
}
return <PacksPage onNewClick={handleNewQueryClick} onEditClick={setEditPackId} />;
};
export const Packs = React.memo(PacksComponent);

View file

@ -0,0 +1,226 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { map } from 'lodash/fp';
import {
EuiBasicTable,
EuiButton,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
RIGHT_ALIGNMENT,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { useQuery, useQueryClient, useMutation } from 'react-query';
import { PackTableQueriesTable } from './pack_table_queries_table';
import { useKibana } from '../../common/lib/kibana';
interface PacksPageProps {
onEditClick: (packId: string) => void;
onNewClick: () => void;
}
const PacksPageComponent: React.FC<PacksPageProps> = ({ onNewClick, onEditClick }) => {
const queryClient = useQueryClient();
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(5);
const [sortField, setSortField] = useState('updated_at');
const [sortDirection, setSortDirection] = useState('desc');
const [selectedItems, setSelectedItems] = useState([]);
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<Record<string, unknown>>({});
const { http } = useKibana().services;
const deletePacksMutation = useMutation(
(payload) => http.delete(`/internal/osquery/pack`, { body: JSON.stringify(payload) }),
{
onSuccess: () => queryClient.invalidateQueries('packList'),
}
);
const { data = {} } = useQuery(
['packList', { pageIndex, pageSize, sortField, sortDirection }],
() =>
http.get('/internal/osquery/pack', {
query: {
pageIndex,
pageSize,
sortField,
sortDirection,
},
}),
{
keepPreviousData: true,
// Refetch the data every 10 seconds
refetchInterval: 5000,
}
);
const { total = 0, saved_objects: packs } = data;
const toggleDetails = useCallback(
(item) => () => {
const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap };
if (itemIdToExpandedRowMapValues[item.id]) {
delete itemIdToExpandedRowMapValues[item.id];
} else {
itemIdToExpandedRowMapValues[item.id] = (
<>
<PackTableQueriesTable items={item.queries} />
<EuiSpacer />
</>
);
}
setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues);
},
[itemIdToExpandedRowMap]
);
const renderExtendedItemToggle = useCallback(
(item) => (
<EuiButtonIcon
onClick={toggleDetails(item)}
aria-label={itemIdToExpandedRowMap[item.id] ? 'Collapse' : 'Expand'}
iconType={itemIdToExpandedRowMap[item.id] ? 'arrowUp' : 'arrowDown'}
/>
),
[itemIdToExpandedRowMap, toggleDetails]
);
const handleEditClick = useCallback((item) => onEditClick(item.id), [onEditClick]);
const columns = useMemo(
() => [
{
field: 'name',
name: 'Pack name',
sortable: true,
truncateText: true,
},
{
field: 'description',
name: 'Description',
sortable: true,
truncateText: true,
},
{
field: 'queries',
name: 'Queries',
sortable: false,
// @ts-expect-error update types
render: (queries) => queries.length,
},
{
field: 'updated_at',
name: 'Last updated at',
sortable: true,
truncateText: true,
},
{
name: 'Actions',
actions: [
{
name: 'Edit',
description: 'Edit or run this query',
type: 'icon',
icon: 'documentEdit',
onClick: handleEditClick,
},
],
},
{
align: RIGHT_ALIGNMENT,
width: '40px',
isExpander: true,
render: renderExtendedItemToggle,
},
],
[handleEditClick, renderExtendedItemToggle]
);
const onTableChange = useCallback(({ page = {}, sort = {} }) => {
setPageIndex(page.index);
setPageSize(page.size);
setSortField(sort.field);
setSortDirection(sort.direction);
}, []);
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
totalItemCount: total,
pageSizeOptions: [3, 5, 8],
}),
[total, pageIndex, pageSize]
);
const sorting = useMemo(
() => ({
sort: {
field: sortField,
direction: sortDirection,
},
}),
[sortDirection, sortField]
);
const selection = useMemo(
() => ({
selectable: () => true,
onSelectionChange: setSelectedItems,
initialSelected: [],
}),
[]
);
const handleDeleteClick = useCallback(() => {
const selectedItemsIds = map<string>('id', selectedItems);
// @ts-expect-error update types
deletePacksMutation.mutate({ packIds: selectedItemsIds });
}, [deletePacksMutation, selectedItems]);
return (
<div>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
{!selectedItems.length ? (
<EuiButton fill onClick={onNewClick}>
{'New pack'}
</EuiButton>
) : (
<EuiButton color="danger" iconType="trash" onClick={handleDeleteClick}>
{`Delete ${selectedItems.length} packs`}
</EuiButton>
)}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
{packs && (
<EuiBasicTable
items={packs}
itemId="id"
// @ts-expect-error update types
columns={columns}
pagination={pagination}
// @ts-expect-error update types
sorting={sorting}
isSelectable={true}
selection={selection}
onChange={onTableChange}
// @ts-expect-error update types
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
rowHeader="id"
/>
)}
</div>
);
};
export const PacksPage = React.memo(PacksPageComponent);

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiBasicTable, EuiCodeBlock } from '@elastic/eui';
import React from 'react';
const columns = [
{
field: 'id',
name: 'ID',
},
{
field: 'name',
name: 'Query name',
},
{
field: 'interval',
name: 'Query interval',
},
{
field: 'query',
name: 'Query',
// eslint-disable-next-line react/display-name
render: (query: string) => (
<EuiCodeBlock language="sql" fontSize="s" paddingSize="s">
{query}
</EuiCodeBlock>
),
},
];
// @ts-expect-error update types
const PackTableQueriesTableComponent = ({ items }) => {
return <EuiBasicTable compressed items={items} rowHeader="id" columns={columns} />;
};
export const PackTableQueriesTable = React.memo(PackTableQueriesTableComponent);

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useMutation } from 'react-query';
import { PackForm } from '../common/pack_form';
import { useKibana } from '../../common/lib/kibana';
interface NewPackPageProps {
onSuccess: () => void;
}
const NewPackPageComponent: React.FC<NewPackPageProps> = ({ onSuccess }) => {
const { http } = useKibana().services;
const addPackMutation = useMutation(
(payload) =>
http.post(`/internal/osquery/pack`, {
body: JSON.stringify(payload),
}),
{
onSuccess,
}
);
// @ts-expect-error update types
return <PackForm handleSubmit={addPackMutation.mutate} />;
};
export const NewPackPage = React.memo(NewPackPageComponent);

View file

@ -5,18 +5,47 @@
* 2.0.
*/
import { BehaviorSubject, Subject } from 'rxjs';
import {
AppMountParameters,
CoreSetup,
Plugin,
PluginInitializerContext,
CoreStart,
} from 'src/core/public';
DEFAULT_APP_CATEGORIES,
AppStatus,
AppUpdater,
} from '../../../../src/core/public';
import { Storage } from '../../../../src/plugins/kibana_utils/public';
import { OsqueryPluginSetup, OsqueryPluginStart, AppPluginStartDependencies } from './types';
import {
OsqueryPluginSetup,
OsqueryPluginStart,
// SetupPlugins,
StartPlugins,
AppPluginStartDependencies,
} from './types';
import { PLUGIN_NAME } from '../common';
import {
LazyOsqueryManagedEmptyCreatePolicyExtension,
LazyOsqueryManagedEmptyEditPolicyExtension,
} from './fleet_integration';
// import { getActionType } from './osquery_action_type';
export function toggleOsqueryPlugin(updater$: Subject<AppUpdater>, http: CoreStart['http']) {
http.fetch('/api/fleet/epm/packages', { query: { experimental: true } }).then(({ response }) => {
const installed = response.find(
// @ts-expect-error update types
(integration) =>
integration?.name === 'osquery_elastic_managed' && integration?.status === 'installed'
);
updater$.next(() => ({
status: installed ? AppStatus.accessible : AppStatus.inaccessible,
}));
});
}
export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginStart> {
private readonly appUpdater$ = new BehaviorSubject<AppUpdater>(() => ({}));
private kibanaVersion: string;
private storage = new Storage(localStorage);
@ -24,7 +53,10 @@ export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginSt
this.kibanaVersion = this.initializerContext.env.packageInfo.version;
}
public setup(core: CoreSetup): OsqueryPluginSetup {
public setup(
core: CoreSetup
// plugins: SetupPlugins
): OsqueryPluginSetup {
const config = this.initializerContext.config.get<{ enabled: boolean }>();
if (!config.enabled) {
@ -37,6 +69,9 @@ export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginSt
core.application.register({
id: 'osquery',
title: PLUGIN_NAME,
order: 9030,
updater$: this.appUpdater$,
category: DEFAULT_APP_CATEGORIES.management,
async mount(params: AppMountParameters) {
// Get start services as specified in kibana.json
const [coreStart, depsStart] = await core.getStartServices();
@ -53,13 +88,50 @@ export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginSt
},
});
// plugins.triggersActionsUi.actionTypeRegistry.register(getActionType());
// Return methods that should be available to other plugins
return {};
}
public start(core: CoreStart): OsqueryPluginStart {
public start(core: CoreStart, plugins: StartPlugins): OsqueryPluginStart {
const config = this.initializerContext.config.get<{ enabled: boolean }>();
if (!config.enabled) {
return {};
}
if (plugins.fleet) {
const { registerExtension } = plugins.fleet;
toggleOsqueryPlugin(this.appUpdater$, core.http);
registerExtension({
package: 'osquery_elastic_managed',
view: 'package-policy-create',
component: LazyOsqueryManagedEmptyCreatePolicyExtension,
});
registerExtension({
package: 'osquery_elastic_managed',
view: 'package-policy-edit',
component: LazyOsqueryManagedEmptyEditPolicyExtension,
});
// registerExtension({
// package: 'osquery_elastic_managed',
// view: 'package-detail-custom',
// component: LazyOsqueryManagedCustomExtension,
// });
} else {
this.appUpdater$.next(() => ({
status: AppStatus.inaccessible,
}));
}
return {};
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
public stop() {}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isEmpty } from 'lodash/fp';
import React from 'react';
import { useMutation, useQuery } from 'react-query';
import { SavedQueryForm } from '../form';
import { useKibana } from '../../common/lib/kibana';
interface EditSavedQueryPageProps {
onSuccess: () => void;
savedQueryId: string;
}
const EditSavedQueryPageComponent: React.FC<EditSavedQueryPageProps> = ({
onSuccess,
savedQueryId,
}) => {
const { http } = useKibana().services;
const { isLoading, data: savedQueryDetails } = useQuery(['savedQuery', { savedQueryId }], () =>
http.get(`/internal/osquery/saved_query/${savedQueryId}`)
);
const updateSavedQueryMutation = useMutation(
(payload) =>
http.put(`/internal/osquery/saved_query/${savedQueryId}`, { body: JSON.stringify(payload) }),
{ onSuccess }
);
if (isLoading) {
return <>{'Loading...'}</>;
}
return (
<>
{!isEmpty(savedQueryDetails) && (
<SavedQueryForm
defaultValue={savedQueryDetails}
// @ts-expect-error update types
handleSubmit={updateSavedQueryMutation.mutate}
type="edit"
/>
)}
</>
);
};
export const EditSavedQueryPage = React.memo(EditSavedQueryPageComponent);

View file

@ -6,14 +6,16 @@
*/
import { EuiTabbedContent, EuiSpacer } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import React, { useMemo } from 'react';
import { ResultsTable } from '../../results/results_table';
import { ActionResultsTable } from '../../action_results/action_results_table';
const ResultTabsComponent = () => {
const { actionId } = useParams<{ actionId: string }>();
interface ResultTabsProps {
actionId: string;
}
const ResultTabsComponent: React.FC<ResultTabsProps> = ({ actionId }) => {
const tabs = useMemo(
() => [
{
@ -40,17 +42,12 @@ const ResultTabsComponent = () => {
[actionId]
);
const handleTabClick = useCallback((tab) => {
// eslint-disable-next-line no-console
console.log('clicked tab', tab);
}, []);
return (
<EuiTabbedContent
tabs={tabs}
initialSelectedTab={tabs[0]}
autoFocus="selected"
onTabClick={handleTabClick}
expand={false}
/>
);
};

View file

@ -5,28 +5,19 @@
* 2.0.
*/
import React, { useCallback } from 'react';
import React from 'react';
import { OsqueryEditor } from '../../editor';
import { FieldHook } from '../../shared_imports';
interface CodeEditorFieldProps {
field: FieldHook<{ query: string }>;
field: FieldHook<string>;
}
const CodeEditorFieldComponent: React.FC<CodeEditorFieldProps> = ({ field }) => {
const { value, setValue } = field;
const handleChange = useCallback(
(newQuery) => {
setValue({
...value,
query: newQuery,
});
},
[value, setValue]
);
return <OsqueryEditor defaultValue={value.query} onChange={handleChange} />;
return <OsqueryEditor defaultValue={value} onChange={setValue} />;
};
export const CodeEditorField = React.memo(CodeEditorFieldComponent);

View file

@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButton, EuiSpacer } from '@elastic/eui';
import React from 'react';
import { Field, getUseField, useForm, UseField, Form } from '../../shared_imports';
import { CodeEditorField } from './code_editor_field';
import { formSchema } from './schema';
export const CommonUseField = getUseField({ component: Field });
const SAVED_QUERY_FORM_ID = 'savedQueryForm';
interface SavedQueryFormProps {
defaultValue?: unknown;
handleSubmit: () => Promise<void>;
type?: string;
}
const SavedQueryFormComponent: React.FC<SavedQueryFormProps> = ({
defaultValue,
handleSubmit,
type,
}) => {
const { form } = useForm({
// @ts-expect-error update types
id: defaultValue ? SAVED_QUERY_FORM_ID + defaultValue.id : SAVED_QUERY_FORM_ID,
schema: formSchema,
onSubmit: handleSubmit,
options: {
stripEmptyFields: false,
},
// @ts-expect-error update types
defaultValue,
});
const { submit } = form;
return (
<Form form={form}>
<CommonUseField path="name" />
<EuiSpacer />
<CommonUseField path="description" />
<EuiSpacer />
<CommonUseField
path="platform"
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
euiFieldProps={{
options: [
{ value: 'darwin', text: 'macOS' },
{ value: 'freebsd', text: 'FreeBSD' },
{ value: 'linux', text: 'Linux' },
{ value: 'posix', text: 'Posix' },
{ value: 'windows', text: 'Windows' },
{ value: 'all', text: 'All' },
],
}}
/>
<EuiSpacer />
<UseField path="query" component={CodeEditorField} />
<EuiSpacer />
<EuiButton onClick={submit}>{type === 'edit' ? 'Update' : 'Save'}</EuiButton>
</Form>
);
};
export const SavedQueryForm = React.memo(SavedQueryFormComponent);

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FIELD_TYPES, FormSchema } from '../../shared_imports';
export const formSchema: FormSchema = {
name: {
type: FIELD_TYPES.TEXT,
label: 'Query name',
},
description: {
type: FIELD_TYPES.TEXTAREA,
label: 'Description',
validations: [],
},
platform: {
type: FIELD_TYPES.SELECT,
label: 'Platform',
defaultValue: 'all',
},
query: {
label: 'Query',
type: FIELD_TYPES.TEXTAREA,
validations: [],
},
};

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useState } from 'react';
import { QueriesPage } from './queries';
import { NewSavedQueryPage } from './new';
import { EditSavedQueryPage } from './edit';
const QueriesComponent = () => {
const [showNewSavedQueryForm, setShowNewSavedQueryForm] = useState(false);
const [editSavedQueryId, setEditSavedQueryId] = useState<string | null>(null);
const goBack = useCallback(() => {
setShowNewSavedQueryForm(false);
setEditSavedQueryId(null);
}, []);
const handleNewQueryClick = useCallback(() => setShowNewSavedQueryForm(true), []);
if (showNewSavedQueryForm) {
return <NewSavedQueryPage onSuccess={goBack} />;
}
if (editSavedQueryId?.length) {
return <EditSavedQueryPage onSuccess={goBack} savedQueryId={editSavedQueryId} />;
}
return <QueriesPage onNewClick={handleNewQueryClick} onEditClick={setEditSavedQueryId} />;
};
export const Queries = React.memo(QueriesComponent);

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useMutation } from 'react-query';
import { useKibana } from '../../common/lib/kibana';
import { SavedQueryForm } from '../form';
interface NewSavedQueryPageProps {
onSuccess: () => void;
}
const NewSavedQueryPageComponent: React.FC<NewSavedQueryPageProps> = ({ onSuccess }) => {
const { http } = useKibana().services;
const createSavedQueryMutation = useMutation(
(payload) => http.post(`/internal/osquery/saved_query`, { body: JSON.stringify(payload) }),
{
onSuccess,
}
);
// @ts-expect-error update types
return <SavedQueryForm handleSubmit={createSavedQueryMutation.mutate} />;
};
export const NewSavedQueryPage = React.memo(NewSavedQueryPageComponent);

View file

@ -0,0 +1,244 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { map } from 'lodash/fp';
import {
EuiBasicTable,
EuiButton,
EuiButtonIcon,
EuiCodeBlock,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
RIGHT_ALIGNMENT,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { useQuery, useQueryClient, useMutation } from 'react-query';
import { useHistory } from 'react-router-dom';
import qs from 'query-string';
import { useKibana } from '../../common/lib/kibana';
interface QueriesPageProps {
onEditClick: (savedQueryId: string) => void;
onNewClick: () => void;
}
const QueriesPageComponent: React.FC<QueriesPageProps> = ({ onEditClick, onNewClick }) => {
const { push } = useHistory();
const queryClient = useQueryClient();
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(5);
const [sortField, setSortField] = useState('updated_at');
const [sortDirection, setSortDirection] = useState('desc');
const [selectedItems, setSelectedItems] = useState([]);
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<Record<string, unknown>>({});
const { http } = useKibana().services;
const deleteSavedQueriesMutation = useMutation(
(payload) => http.delete(`/internal/osquery/saved_query`, { body: JSON.stringify(payload) }),
{
onSuccess: () => queryClient.invalidateQueries('savedQueryList'),
}
);
const { data = {} } = useQuery(
['savedQueryList', { pageIndex, pageSize, sortField, sortDirection }],
() =>
http.get('/internal/osquery/saved_query', {
query: {
pageIndex,
pageSize,
sortField,
sortDirection,
},
}),
{
keepPreviousData: true,
// Refetch the data every 10 seconds
refetchInterval: 5000,
}
);
const { total = 0, saved_objects: savedQueries } = data;
const toggleDetails = useCallback(
(item) => () => {
const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap };
if (itemIdToExpandedRowMapValues[item.id]) {
delete itemIdToExpandedRowMapValues[item.id];
} else {
itemIdToExpandedRowMapValues[item.id] = (
<EuiCodeBlock language="sql" fontSize="m" paddingSize="m">
{item.attributes.query}
</EuiCodeBlock>
);
}
setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues);
},
[itemIdToExpandedRowMap]
);
const renderExtendedItemToggle = useCallback(
(item) => (
<EuiButtonIcon
onClick={toggleDetails(item)}
aria-label={itemIdToExpandedRowMap[item.id] ? 'Collapse' : 'Expand'}
iconType={itemIdToExpandedRowMap[item.id] ? 'arrowUp' : 'arrowDown'}
/>
),
[itemIdToExpandedRowMap, toggleDetails]
);
const handleEditClick = useCallback((item) => onEditClick(item.id), [onEditClick]);
const handlePlayClick = useCallback(
(item) =>
push({
search: qs.stringify({
tab: 'live_query',
}),
state: {
query: {
id: item.id,
query: item.attributes.query,
},
},
}),
[push]
);
const columns = useMemo(
() => [
{
field: 'attributes.name',
name: 'Query name',
sortable: true,
truncateText: true,
},
{
field: 'attributes.description',
name: 'Description',
sortable: true,
truncateText: true,
},
{
field: 'updated_at',
name: 'Last updated at',
sortable: true,
truncateText: true,
},
{
name: 'Actions',
actions: [
{
name: 'Live query',
description: 'Run live query',
type: 'icon',
icon: 'play',
onClick: handlePlayClick,
},
{
name: 'Edit',
description: 'Edit or run this query',
type: 'icon',
icon: 'documentEdit',
onClick: handleEditClick,
},
],
},
{
align: RIGHT_ALIGNMENT,
width: '40px',
isExpander: true,
render: renderExtendedItemToggle,
},
],
[handleEditClick, handlePlayClick, renderExtendedItemToggle]
);
const onTableChange = useCallback(({ page = {}, sort = {} }) => {
setPageIndex(page.index);
setPageSize(page.size);
setSortField(sort.field);
setSortDirection(sort.direction);
}, []);
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
totalItemCount: total,
pageSizeOptions: [3, 5, 8],
}),
[total, pageIndex, pageSize]
);
const sorting = useMemo(
() => ({
sort: {
field: sortField,
direction: sortDirection,
},
}),
[sortDirection, sortField]
);
const selection = useMemo(
() => ({
selectable: () => true,
onSelectionChange: setSelectedItems,
initialSelected: [],
}),
[]
);
const handleDeleteClick = useCallback(() => {
const selectedItemsIds = map<string>('id', selectedItems);
// @ts-expect-error update types
deleteSavedQueriesMutation.mutate({ savedQueryIds: selectedItemsIds });
}, [deleteSavedQueriesMutation, selectedItems]);
return (
<div>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
{!selectedItems.length ? (
<EuiButton fill onClick={onNewClick}>
{'New query'}
</EuiButton>
) : (
<EuiButton color="danger" iconType="trash" onClick={handleDeleteClick}>
{`Delete ${selectedItems.length} Queries`}
</EuiButton>
)}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
{savedQueries && (
<EuiBasicTable
items={savedQueries}
itemId="id"
// @ts-expect-error update types
columns={columns}
pagination={pagination}
// @ts-expect-error update types
sorting={sorting}
isSelectable={true}
selection={selection}
onChange={onTableChange}
// @ts-expect-error update types
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
rowHeader="id"
/>
)}
</div>
);
};
export const QueriesPage = React.memo(QueriesPageComponent);

View file

@ -6,20 +6,22 @@
*/
import { isEmpty, isEqual, keys, map } from 'lodash/fp';
import { EuiDataGrid, EuiDataGridProps, EuiDataGridColumn } from '@elastic/eui';
import { EuiDataGrid, EuiDataGridProps, EuiDataGridColumn, EuiLink } from '@elastic/eui';
import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react';
import { EuiDataGridSorting } from '@elastic/eui';
import { useAllResults } from './use_all_results';
import { Direction, ResultEdges } from '../../common/search_strategy';
import { useRouterNavigate } from '../common/lib/kibana';
const DataContext = createContext<ResultEdges>([]);
interface ResultsTableComponentProps {
actionId: string;
agentId?: string;
}
const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({ actionId }) => {
const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({ actionId, agentId }) => {
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 });
const onChangeItemsPerPage = useCallback(
(pageSize) =>
@ -46,8 +48,9 @@ const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({ actionId
[setSortingColumns]
);
const [, { results, totalCount }] = useAllResults({
const { data: allResultsData = [] } = useAllResults({
actionId,
agentId,
activePage: pagination.pageIndex,
limit: pagination.pageSize,
direction: Direction.asc,
@ -61,15 +64,22 @@ const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({ actionId
]);
const renderCellValue: EuiDataGridProps['renderCellValue'] = useMemo(
() => ({ rowIndex, columnId, setCellProps }) => {
() => ({ rowIndex, columnId }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const data = useContext(DataContext);
const value = data[rowIndex].fields[columnId];
if (columnId === 'agent.name') {
const agentIdValue = data[rowIndex].fields['agent.id'];
// eslint-disable-next-line react-hooks/rules-of-hooks
const linkProps = useRouterNavigate(`/live_query/${actionId}/results/${agentIdValue}`);
return <EuiLink {...linkProps}>{value}</EuiLink>;
}
return !isEmpty(value) ? value : '-';
},
[]
[actionId]
);
const tableSorting = useMemo(() => ({ columns: sortingColumns, onSort }), [
@ -88,30 +98,59 @@ const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({ actionId
);
useEffect(() => {
const newColumns: EuiDataGridColumn[] = keys(results[0]?.fields)
// @ts-expect-error update types
if (!allResultsData?.results) {
return;
}
// @ts-expect-error update types
const newColumns = keys(allResultsData?.results[0]?.fields)
.sort()
.map((fieldName) => ({
id: fieldName,
displayAsText: fieldName.split('.')[1],
defaultSortDirection: 'asc',
}));
.reduce((acc, fieldName) => {
if (fieldName === 'agent.name') {
return [
...acc,
{
id: fieldName,
displayAsText: 'agent',
defaultSortDirection: Direction.asc,
},
];
}
if (fieldName.startsWith('osquery.')) {
return [
...acc,
{
id: fieldName,
displayAsText: fieldName.split('.')[1],
defaultSortDirection: Direction.asc,
},
];
}
return acc;
}, [] as EuiDataGridColumn[]);
if (!isEqual(columns, newColumns)) {
setColumns(newColumns);
setVisibleColumns(map('id', newColumns));
}
}, [columns, results]);
// @ts-expect-error update types
}, [columns, allResultsData?.results]);
return (
<DataContext.Provider value={results}>
// @ts-expect-error update types
<DataContext.Provider value={allResultsData?.results}>
<EuiDataGrid
aria-label="Osquery results"
columns={columns}
columnVisibility={columnVisibility}
rowCount={totalCount}
// @ts-expect-error update types
rowCount={allResultsData?.totalCount ?? 0}
renderCellValue={renderCellValue}
sorting={tableSorting}
pagination={tablePagination}
height="300px"
/>
</DataContext.Provider>
);

View file

@ -6,14 +6,14 @@
*/
import deepEqual from 'fast-deep-equal';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery } from 'react-query';
import { createFilter } from '../common/helpers';
import { useKibana } from '../common/lib/kibana';
import {
ResultEdges,
PageInfoPaginated,
DocValueFields,
OsqueryQueries,
ResultsRequestOptions,
ResultsStrategyResponse,
@ -21,13 +21,8 @@ import {
} from '../../common/search_strategy';
import { ESTermQuery } from '../../common/typed_json';
import * as i18n from './translations';
import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common';
import { AbortError } from '../../../../../src/plugins/kibana_utils/common';
import { generateTablePaginationOptions, getInspectResponse, InspectResponse } from './helpers';
const ID = 'resultsAllQuery';
export interface ResultsArgs {
results: ResultEdges;
id: string;
@ -40,10 +35,10 @@ export interface ResultsArgs {
interface UseAllResults {
actionId: string;
activePage: number;
agentId?: string;
direction: Direction;
limit: number;
sortField: string;
docValueFields?: DocValueFields[];
filterQuery?: ESTermQuery | string;
skip?: boolean;
}
@ -51,89 +46,38 @@ interface UseAllResults {
export const useAllResults = ({
actionId,
activePage,
agentId,
direction,
limit,
sortField,
docValueFields,
filterQuery,
skip = false,
}: UseAllResults): [boolean, ResultsArgs] => {
const { data, notifications } = useKibana().services;
}: UseAllResults) => {
const { data } = useKibana().services;
const abortCtrl = useRef(new AbortController());
const [loading, setLoading] = useState(false);
const [resultsRequest, setHostRequest] = useState<ResultsRequestOptions | null>(null);
const [resultsResponse, setResultsResponse] = useState<ResultsArgs>({
results: [],
id: ID,
inspect: {
dsl: [],
response: [],
},
isInspected: false,
pageInfo: {
activePage: 0,
fakeTotalCount: 0,
showMorePagesIndicator: false,
},
totalCount: -1,
});
const response = useQuery(
['allActionResults', { actionId, activePage, direction, limit, sortField }],
async () => {
if (!resultsRequest) return Promise.resolve();
const resultsSearch = useCallback(
(request: ResultsRequestOptions | null) => {
if (request == null || skip) {
return;
}
const responseData = await data.search
.search<ResultsRequestOptions, ResultsStrategyResponse>(resultsRequest, {
strategy: 'osquerySearchStrategy',
})
.toPromise();
let didCancel = false;
const asyncSearch = async () => {
abortCtrl.current = new AbortController();
setLoading(true);
const searchSubscription$ = data.search
.search<ResultsRequestOptions, ResultsStrategyResponse>(request, {
strategy: 'osquerySearchStrategy',
abortSignal: abortCtrl.current.signal,
})
.subscribe({
next: (response) => {
if (isCompleteResponse(response)) {
if (!didCancel) {
setLoading(false);
setResultsResponse((prevResponse) => ({
...prevResponse,
results: response.edges,
inspect: getInspectResponse(response, prevResponse.inspect),
pageInfo: response.pageInfo,
totalCount: response.totalCount,
}));
}
searchSubscription$.unsubscribe();
} else if (isErrorResponse(response)) {
if (!didCancel) {
setLoading(false);
}
// TODO: Make response error status clearer
notifications.toasts.addWarning(i18n.ERROR_ALL_RESULTS);
searchSubscription$.unsubscribe();
}
},
error: (msg) => {
if (!(msg instanceof AbortError)) {
notifications.toasts.addDanger({ title: i18n.FAIL_ALL_RESULTS, text: msg.message });
}
},
});
};
abortCtrl.current.abort();
asyncSearch();
return () => {
didCancel = true;
abortCtrl.current.abort();
return {
...responseData,
results: responseData.edges,
inspect: getInspectResponse(responseData, {} as InspectResponse),
};
},
[data.search, notifications.toasts, skip]
{
refetchInterval: 1000,
enabled: !skip && !!resultsRequest,
}
);
useEffect(() => {
@ -141,7 +85,7 @@ export const useAllResults = ({
const myRequest = {
...(prevRequest ?? {}),
actionId,
docValueFields: docValueFields ?? [],
agentId,
factoryQueryType: OsqueryQueries.results,
filterQuery: createFilter(filterQuery),
pagination: generateTablePaginationOptions(activePage, limit),
@ -155,11 +99,7 @@ export const useAllResults = ({
}
return prevRequest;
});
}, [actionId, activePage, direction, docValueFields, filterQuery, limit, sortField]);
}, [actionId, activePage, agentId, direction, filterQuery, limit, sortField]);
useEffect(() => {
resultsSearch(resultsRequest);
}, [resultsRequest, resultsSearch]);
return [loading, resultsResponse];
return response;
};

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { Switch, Redirect, Route } from 'react-router-dom';
import { LiveQueries } from './live_query';
const OsqueryAppRoutesComponent = () => (
<Switch>
{/* <Route path="/packs">
<Packs />
</Route>
<Route path={`/scheduled_queries`}>
<ScheduledQueries />
</Route>
<Route path={`/queries`}>
<Queries />
</Route> */}
<Route path="/live_query">
<LiveQueries />
</Route>
<Redirect to="/live_query" />
</Switch>
);
export const OsqueryAppRoutes = React.memo(OsqueryAppRoutesComponent);

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiButtonEmpty,
EuiText,
EuiFlexGroup,
EuiFlexItem,
EuiCodeBlock,
EuiSpacer,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { useRouterNavigate } from '../../../common/lib/kibana';
import { WithHeaderLayout } from '../../../components/layouts';
import { useActionDetails } from '../../../actions/use_action_details';
import { ResultsTable } from '../../../results/results_table';
const LiveQueryAgentDetailsPageComponent = () => {
const { actionId, agentId } = useParams<{ actionId: string; agentId: string }>();
const { data } = useActionDetails({ actionId });
const liveQueryListProps = useRouterNavigate(`live_query/${actionId}`);
const LeftColumn = useMemo(
() => (
<EuiFlexGroup alignItems="flexStart" direction="column" gutterSize="m">
<EuiFlexItem>
<EuiButtonEmpty iconType="arrowLeft" {...liveQueryListProps} flush="left" size="xs">
<FormattedMessage
id="xpack.osquery.liveQueryAgentDetails.viewLiveQueryResultsTitle"
defaultMessage="View all live query results"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiText>
<h1>
<FormattedMessage
id="xpack.osquery.liveQueryAgentDetails.pageTitle"
defaultMessage="Live query {agentId} agent results"
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
values={{ agentId }}
/>
</h1>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText color="subdued">
<p>
<FormattedMessage
id="xpack.osquery.liveQueryAgentDetails.pageSubtitle"
defaultMessage="Lorem ipsum dolor sit amet, consectetur adipiscing elit."
/>
</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
),
[agentId, liveQueryListProps]
);
return (
<WithHeaderLayout leftColumn={LeftColumn}>
<EuiCodeBlock language="sql" fontSize="m" paddingSize="m">
{
// @ts-expect-error update types
data?.actionDetails._source?.data?.query
}
</EuiCodeBlock>
<EuiSpacer />
<ResultsTable actionId={actionId} agentId={agentId} />
</WithHeaderLayout>
);
};
export const LiveQueryAgentDetailsPage = React.memo(LiveQueryAgentDetailsPageComponent);

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButton, EuiContextMenuPanel, EuiContextMenuItem, EuiPopover } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { useKibana } from '../../../common/lib/kibana';
interface LiveQueryDetailsActionsMenuProps {
actionId: string;
}
const LiveQueryDetailsActionsMenuComponent: React.FC<LiveQueryDetailsActionsMenuProps> = ({
actionId,
}) => {
const services = useKibana().services;
const [isPopoverOpen, setPopover] = useState(false);
const discoverLinkHref = services?.application?.getUrlForApp('discover', {
path: `#/?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:now-24h,to:now))&_a=(columns:!(),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:'logs-*',key:action_id,negate:!f,params:(query:'${actionId}'),type:phrase),query:(match_phrase:(action_id:'${actionId}')))),index:'logs-*',interval:auto,query:(language:kuery,query:''),sort:!(!('@timestamp',desc)))`,
});
const onButtonClick = useCallback(() => {
setPopover((currentIsPopoverOpen) => !currentIsPopoverOpen);
}, []);
const closePopover = useCallback(() => {
setPopover(false);
}, []);
const items = useMemo(
() => [
<EuiContextMenuItem key="copy" icon="copy" href={discoverLinkHref}>
Check results in Discover
</EuiContextMenuItem>,
],
[discoverLinkHref]
);
const button = (
<EuiButton iconType="arrowDown" iconSide="right" onClick={onButtonClick}>
Actions
</EuiButton>
);
return (
<EuiPopover
id="liveQueryDetailsActionsMenu"
button={button}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
>
<EuiContextMenuPanel size="s" items={items} />
</EuiPopover>
);
};
export const LiveQueryDetailsActionsMenu = React.memo(LiveQueryDetailsActionsMenuComponent);

View file

@ -0,0 +1,167 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiButtonEmpty,
EuiText,
EuiFlexGroup,
EuiFlexItem,
EuiCodeBlock,
EuiSpacer,
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import styled from 'styled-components';
import { Direction } from '../../../../common/search_strategy';
import { useRouterNavigate } from '../../../common/lib/kibana';
import { WithHeaderLayout } from '../../../components/layouts';
import { useActionResults } from '../../../action_results/use_action_results';
import { useActionDetails } from '../../../actions/use_action_details';
import { ResultTabs } from '../../../queries/edit/tabs';
import { LiveQueryDetailsActionsMenu } from './actions_menu';
const Divider = styled.div`
width: 0;
height: 100%;
border-left: ${({ theme }) => theme.eui.euiBorderThin};
`;
const LiveQueryDetailsPageComponent = () => {
const { actionId } = useParams<{ actionId: string }>();
const liveQueryListProps = useRouterNavigate('live_query');
const { data } = useActionDetails({ actionId });
const { data: actionResultsData } = useActionResults({
actionId,
activePage: 0,
limit: 0,
direction: Direction.asc,
sortField: '@timestamp',
});
const LeftColumn = useMemo(
() => (
<EuiFlexGroup alignItems="flexStart" direction="column" gutterSize="m">
<EuiFlexItem>
<EuiButtonEmpty iconType="arrowLeft" {...liveQueryListProps} flush="left" size="xs">
<FormattedMessage
id="xpack.osquery.liveQueryDetails.viewLiveQueriesListTitle"
defaultMessage="View all live queries"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiText>
<h1>
<FormattedMessage
id="xpack.osquery.liveQueryDetails.pageTitle"
defaultMessage="Live query results"
/>
</h1>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText color="subdued">
<p>
<FormattedMessage
id="xpack.osquery.liveQueryDetails.pageSubtitle"
defaultMessage="Lorem ipsum dolor sit amet, consectetur adipiscing elit."
/>
</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
),
[liveQueryListProps]
);
const RightColumn = useMemo(
() => (
<EuiFlexGroup justifyContent="flexEnd" direction="row">
<EuiFlexItem grow={false} key="rows_count">
<></>
</EuiFlexItem>
<EuiFlexItem grow={false} key="rows_count_divider">
<Divider />
</EuiFlexItem>
<EuiFlexItem grow={false} key="agents_count">
{/* eslint-disable-next-line react-perf/jsx-no-new-object-as-prop */}
<EuiDescriptionList compressed textStyle="reverse" style={{ textAlign: 'right' }}>
<EuiDescriptionListTitle className="eui-textNoWrap">
<FormattedMessage
id="xpack.osquery.liveQueryDetails.kpis.agentsQueriedLabelText"
defaultMessage="Agents queried"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textNoWrap">
{
// @ts-expect-error update types
data?.actionDetails?.fields?.agents?.length ?? '0'
}
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
<EuiFlexItem grow={false} key="agents_count_divider">
<Divider />
</EuiFlexItem>
<EuiFlexItem grow={false} key="agents_failed_count">
{/* eslint-disable-next-line react-perf/jsx-no-new-object-as-prop */}
<EuiDescriptionList compressed textStyle="reverse" style={{ textAlign: 'right' }}>
<EuiDescriptionListTitle className="eui-textNoWrap">
<FormattedMessage
id="xpack.osquery.liveQueryDetails.kpis.agentsFailedCountLabelText"
defaultMessage="Agents failed"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textNoWrap">
{
// @ts-expect-error update types
actionResultsData?.rawResponse?.aggregations?.responses?.buckets.find(
// @ts-expect-error update types
(bucket) => bucket.key === 'error'
)?.doc_count ?? '0'
}
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
<EuiFlexItem grow={false} key="agents_count_divider">
<Divider />
</EuiFlexItem>
<EuiFlexItem grow={false} key="actions_menu">
<LiveQueryDetailsActionsMenu actionId={actionId} />
</EuiFlexItem>
</EuiFlexGroup>
),
[
actionId,
// @ts-expect-error update types
actionResultsData?.rawResponse?.aggregations?.responses?.buckets,
// @ts-expect-error update types
data?.actionDetails?.fields?.agents?.length,
]
);
return (
<WithHeaderLayout leftColumn={LeftColumn} rightColumn={RightColumn} rightColumnGrow={false}>
<EuiCodeBlock language="sql" fontSize="m" paddingSize="m">
{
// @ts-expect-error update types
data?.actionDetails._source?.data?.query
}
</EuiCodeBlock>
<EuiSpacer />
<ResultTabs actionId={actionId} />
</WithHeaderLayout>
);
};
export const LiveQueryDetailsPage = React.memo(LiveQueryDetailsPageComponent);

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { Switch, Route, useRouteMatch } from 'react-router-dom';
import { LiveQueriesPage } from './list';
import { NewLiveQueryPage } from './new';
import { LiveQueryDetailsPage } from './details';
import { LiveQueryAgentDetailsPage } from './agent_details';
const LiveQueriesComponent = () => {
const match = useRouteMatch();
return (
<Switch>
<Route path={`${match.url}/new`}>
<NewLiveQueryPage />
</Route>
<Route path={`${match.url}/:actionId/results/:agentId`}>
<LiveQueryAgentDetailsPage />
</Route>
<Route path={`${match.url}/:actionId`}>
<LiveQueryDetailsPage />
</Route>
<Route path={`${match.url}`}>
<LiveQueriesPage />
</Route>
</Switch>
);
};
export const LiveQueries = React.memo(LiveQueriesComponent);

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButton, EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useMemo } from 'react';
import { useRouterNavigate } from '../../../common/lib/kibana';
import { ActionsTable } from '../../../actions/actions_table';
import { WithHeaderLayout } from '../../../components/layouts';
const LiveQueriesPageComponent = () => {
const newQueryLinkProps = useRouterNavigate('live_query/new');
const LeftColumn = useMemo(
() => (
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem>
<EuiText>
<h1>
<FormattedMessage
id="xpack.osquery.liveQueryList.pageTitle"
defaultMessage="Live queries"
/>
</h1>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText color="subdued">
<p>
<FormattedMessage
id="xpack.osquery.liveQueryList.pageSubtitle"
defaultMessage="Lorem ipsum dolor sit amet, consectetur adipiscing elit."
/>
</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
),
[]
);
const RightColumn = useMemo(
() => (
<EuiButton fill {...newQueryLinkProps}>
{'New live query'}
</EuiButton>
),
[newQueryLinkProps]
);
return (
<WithHeaderLayout leftColumn={LeftColumn} rightColumn={RightColumn} rightColumnGrow={false}>
<ActionsTable />
</WithHeaderLayout>
);
};
export const LiveQueriesPage = React.memo(LiveQueriesPageComponent);

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButtonEmpty, EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useMemo } from 'react';
import { WithHeaderLayout } from '../../../components/layouts';
import { useRouterNavigate } from '../../../common/lib/kibana';
import { LiveQuery } from '../../../live_query';
const NewLiveQueryPageComponent = () => {
const liveQueryListProps = useRouterNavigate('live_query');
const LeftColumn = useMemo(
() => (
<EuiFlexGroup alignItems="flexStart" direction="column" gutterSize="m">
<EuiFlexItem>
<EuiButtonEmpty iconType="arrowLeft" {...liveQueryListProps} flush="left" size="xs">
<FormattedMessage
id="xpack.osquery.newLiveQuery.viewLiveQueriesListTitle"
defaultMessage="View all live queries"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiText>
<h1>
<FormattedMessage
id="xpack.osquery.newLiveQuery.pageTitle"
defaultMessage="New Live query"
/>
</h1>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText color="subdued">
<p>
<FormattedMessage
id="xpack.osquery.newLiveQuery.pageSubtitle"
defaultMessage="Lorem ipsum dolor sit amet, consectetur adipiscing elit."
/>
</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
),
[liveQueryListProps]
);
return (
<WithHeaderLayout leftColumn={LeftColumn}>
<LiveQuery />
</WithHeaderLayout>
);
};
export const NewLiveQueryPage = React.memo(NewLiveQueryPageComponent);

View file

@ -0,0 +1,169 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { find } from 'lodash/fp';
import {
EuiButtonIcon,
EuiFieldText,
EuiForm,
EuiFormRow,
EuiSelect,
EuiSpacer,
EuiSwitch,
EuiHorizontalRule,
} from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { useQuery } from 'react-query';
import { useKibana } from '../../common/lib/kibana';
// @ts-expect-error update types
const OsqueryStreamFieldComponent = ({ field, removeItem }) => {
const { http } = useKibana().services;
const { data: { saved_objects: savedQueries } = {} } = useQuery(['savedQueryList'], () =>
http.get('/internal/osquery/saved_query', {
query: { pageIndex: 0, pageSize: 100, sortField: 'updated_at', sortDirection: 'desc' },
})
);
const { setValue } = field;
const savedQueriesOptions = useMemo(
() =>
// @ts-expect-error update types
(savedQueries ?? []).map((savedQuery) => ({
text: savedQuery.attributes.name,
value: savedQuery.id,
})),
[savedQueries]
);
const handleSavedQueryChange = useCallback(
(event) => {
event.persist();
const savedQueryId = event.target.value;
const savedQuery = find(['id', savedQueryId], savedQueries);
if (savedQuery) {
// @ts-expect-error update types
setValue((prev) => ({
...prev,
vars: {
...prev.vars,
id: {
...prev.vars.id,
value: savedQuery.id,
},
query: {
...prev.vars.query,
value: savedQuery.attributes.query,
},
},
}));
}
},
[savedQueries, setValue]
);
const handleEnabledChange = useCallback(() => {
// @ts-expect-error update types
setValue((prev) => ({
...prev,
enabled: !prev.enabled,
}));
}, [setValue]);
const handleQueryChange = useCallback(
(event) => {
event.persist();
// @ts-expect-error update types
setValue((prev) => ({
...prev,
vars: {
...prev.vars,
query: {
...prev.vars.query,
value: event.target.value,
},
},
}));
},
[setValue]
);
const handleIntervalChange = useCallback(
(event) => {
event.persist();
// @ts-expect-error update types
setValue((prev) => ({
...prev,
vars: {
...prev.vars,
interval: {
...prev.vars.interval,
value: event.target.value,
},
},
}));
},
[setValue]
);
const handleIdChange = useCallback(
(event) => {
event.persist();
// @ts-expect-error update types
setValue((prev) => ({
...prev,
vars: {
...prev.vars,
id: {
...prev.vars.id,
value: event.target.value,
},
},
}));
},
[setValue]
);
return (
<EuiForm>
<EuiFormRow>
<EuiSwitch label="Enabled" checked={field.value.enabled} onChange={handleEnabledChange} />
</EuiFormRow>
<EuiSpacer />
<EuiFormRow>
<EuiButtonIcon aria-label="remove" onClick={removeItem} color="danger" iconType="trash" />
</EuiFormRow>
<EuiFormRow>
<EuiSelect
value={field.value.vars.id.value}
hasNoInitialSelection
options={savedQueriesOptions}
onChange={handleSavedQueryChange}
/>
</EuiFormRow>
<EuiSpacer />
<EuiFormRow>
<EuiFieldText value={field.value.vars.query.value} onChange={handleQueryChange} />
</EuiFormRow>
<EuiSpacer />
<EuiFormRow>
<EuiFieldText value={field.value.vars.interval.value} onChange={handleIntervalChange} />
</EuiFormRow>
<EuiSpacer />
<EuiFormRow>
<EuiFieldText value={field.value.vars.id.value} onChange={handleIdChange} />
</EuiFormRow>
<EuiSpacer />
<EuiHorizontalRule />
</EuiForm>
);
};
export const OsqueryStreamField = React.memo(OsqueryStreamFieldComponent);

View file

@ -0,0 +1,153 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import produce from 'immer';
import { get, omit } from 'lodash/fp';
import { EuiButton, EuiButtonEmpty, EuiSpacer, EuiHorizontalRule } from '@elastic/eui';
import uuid from 'uuid';
import React, { useMemo } from 'react';
import {
UseField,
useForm,
UseArray,
getUseField,
Field,
ToggleField,
Form,
} from '../../shared_imports';
import { OsqueryStreamField } from '../common/osquery_stream_field';
import { schema } from './schema';
const CommonUseField = getUseField({ component: Field });
const EDIT_SCHEDULED_QUERY_FORM_ID = 'editScheduledQueryForm';
interface EditScheduledQueryFormProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agentPolicies: Array<Record<string, any>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: Array<Record<string, any>>;
handleSubmit: () => Promise<void>;
}
const EditScheduledQueryFormComponent: React.FC<EditScheduledQueryFormProps> = ({
agentPolicies,
data,
handleSubmit,
}) => {
const agentPoliciesOptions = useMemo(
() =>
agentPolicies.map((policy) => ({
value: policy.id,
text: policy.name,
})),
[agentPolicies]
);
const { form } = useForm({
schema,
id: EDIT_SCHEDULED_QUERY_FORM_ID,
onSubmit: handleSubmit,
defaultValue: data,
// @ts-expect-error update types
deserializer: (payload) => {
const deserialized = produce(payload, (draft) => {
// @ts-expect-error update types
draft.inputs[0].streams.forEach((stream) => {
delete stream.compiled_stream;
});
});
return deserialized;
},
// @ts-expect-error update types
serializer: (payload) =>
omit(['id', 'revision', 'created_at', 'created_by', 'updated_at', 'updated_by', 'version'], {
...data,
...payload,
// @ts-expect-error update types
inputs: [{ type: 'osquery', ...((payload.inputs && payload.inputs[0]) ?? {}) }],
}),
});
const { submit } = form;
const policyIdComponentProps = useMemo(
() => ({
euiFieldProps: {
disabled: true,
options: agentPoliciesOptions,
},
}),
[agentPoliciesOptions]
);
return (
<Form form={form}>
<CommonUseField path="policy_id" componentProps={policyIdComponentProps} />
<EuiSpacer />
<CommonUseField path="name" />
<EuiSpacer />
<CommonUseField path="description" />
<EuiSpacer />
<CommonUseField path="inputs[0].enabled" component={ToggleField} />
<EuiHorizontalRule />
<EuiSpacer />
<UseArray path="inputs[0].streams">
{({ items, addItem, removeItem }) => (
<>
{items.map((item) => (
<UseField
key={item.path}
path={item.path}
component={OsqueryStreamField}
// eslint-disable-next-line react/jsx-no-bind, react-perf/jsx-no-new-function-as-prop
removeItem={() => removeItem(item.id)}
defaultValue={
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
get(item.path, form.getFormData()) ?? {
data_stream: {
type: 'logs',
dataset: 'osquery_elastic_managed.osquery',
},
vars: {
query: {
type: 'text',
value: 'select * from uptime',
},
interval: {
type: 'text',
value: '120',
},
id: {
type: 'text',
value: uuid.v4(),
},
},
enabled: true,
}
}
/>
))}
<EuiButtonEmpty onClick={addItem} iconType="plusInCircleFilled">
{'Add query'}
</EuiButtonEmpty>
</>
)}
</UseArray>
<EuiHorizontalRule />
<EuiSpacer />
<EuiButton fill onClick={submit}>
Save
</EuiButton>
</Form>
);
};
export const EditScheduledQueryForm = React.memo(EditScheduledQueryFormComponent);

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useParams } from 'react-router-dom';
import { useMutation, useQuery } from 'react-query';
import { useKibana } from '../../common/lib/kibana';
import { EditScheduledQueryForm } from './form';
const EditScheduledQueryPageComponent = () => {
const { http } = useKibana().services;
const { scheduledQueryId } = useParams<{ scheduledQueryId: string }>();
const { data } = useQuery(['scheduledQuery', { scheduledQueryId }], () =>
http.get(`/internal/osquery/scheduled_query/${scheduledQueryId}`)
);
const { data: agentPolicies } = useQuery(
['agentPolicy'],
() => http.get(`/api/fleet/agent_policies`),
{ initialData: { items: [] } }
);
const updateScheduledQueryMutation = useMutation((payload) =>
http.put(`/api/fleet/package_policies/${scheduledQueryId}`, { body: JSON.stringify(payload) })
);
if (data) {
return (
<EditScheduledQueryForm
data={data}
// @ts-expect-error update types
agentPolicies={agentPolicies?.items}
// @ts-expect-error update types
handleSubmit={updateScheduledQueryMutation.mutate}
/>
);
}
return <div>Loading</div>;
};
export const EditScheduledQueryPage = React.memo(EditScheduledQueryPageComponent);

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